@pi-archimedes/footer 2.10.4 → 2.11.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.
@@ -0,0 +1,380 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /**
5
+ * The footer paints git/jj facts that are refreshed in the background, so a
6
+ * refresh has to ask for a frame or the stale value stays on screen. These
7
+ * tests cover the wiring — git.test.ts / jj.test.ts cover when the bus
8
+ * publishes.
9
+ */
10
+
11
+ // Load the footer's module graph at COLLECT time, before any test starts.
12
+ //
13
+ // Every test below builds a footer from a freshly imported module (`vi.resetModules`
14
+ // + dynamic import), and the first of those imports used to happen inside the
15
+ // first test — so the first test was charged the one-off cost of transforming
16
+ // and instantiating the whole graph (`index.ts` → `pi-tui` → `@pi-archimedes/core`
17
+ // → …): 1.1s when this file runs alone, 2.6s in a full suite run, 8.3s measured
18
+ // on a loaded machine. That is not a test failure, it is a test that spent the
19
+ // 5s default budget loading code, so under CPU contention the run dies with
20
+ // "Test timed out in 5000ms" on whichever test happened to be cold — this one,
21
+ // in practice, and it passed on every re-run, which is what made it look like an
22
+ // ordering race. Collection has no per-test timeout, so paying the cost here
23
+ // removes the race instead of outwaiting it; the tests still get a fresh module
24
+ // instance each time from `vi.resetModules()`, now against a warm graph (~20ms).
25
+ import "./index.js";
26
+
27
+ interface FooterInstance {
28
+ render(width: number): string[];
29
+ dispose(): void;
30
+ invalidate(): void;
31
+ }
32
+
33
+ type FooterFactory = (tui: { requestRender: () => void }, theme: unknown, footerData: unknown) => FooterInstance;
34
+
35
+ interface HarnessOptions {
36
+ /** Make the session-entry read the footer depends on throw. */
37
+ failStats?: boolean;
38
+ /** Override `ctx.ui.notify` (to simulate a UI that cannot be told). */
39
+ brokenNotify?: boolean;
40
+ }
41
+
42
+ interface Harness {
43
+ factory: FooterFactory;
44
+ notify: () => void;
45
+ shutdown: () => void;
46
+ /** What the footer pushed at `ctx.ui.notify`. */
47
+ userNotice: ReturnType<typeof vi.fn>;
48
+ /** The mocked `getGitStatus`, spied so a test can prove the footer loaded it. */
49
+ gitStatus: ReturnType<typeof vi.fn>;
50
+ /** What the footer pushed at the diagnostics sink. */
51
+ diagCalls: Array<[string, string]>;
52
+ }
53
+
54
+ const THEME = { fg: (_token: string, s: string) => s };
55
+
56
+ function makeFooterData() {
57
+ return {
58
+ onBranchChange: () => vi.fn(),
59
+ getGitBranch: () => "main",
60
+ getExtensionStatuses: () => new Map<string, string>(),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Footers built by the test that is currently running, so `afterEach` can hand
66
+ * each one back. The VCS-change bus is a module-level listener set, so a test
67
+ * that built a footer and never disposed it leaves a subscriber behind; today
68
+ * that is invisible only because `loadFooter` resets the module registry and
69
+ * takes a fresh listener set with it. Disposing here keeps each test owning its
70
+ * own subscriptions rather than relying on the reset to launder them — and what
71
+ * `dispose()` actually hands back is asserted by the tests below, not assumed
72
+ * by this hook: drop a release from `dispose()` and one of them goes red.
73
+ */
74
+ let builtFooters: FooterInstance[] = [];
75
+
76
+ afterEach(() => {
77
+ for (const footer of builtFooters) footer.dispose();
78
+ builtFooters = [];
79
+ });
80
+
81
+ async function loadFooter(options: HarnessOptions = {}): Promise<Harness> {
82
+ vi.resetModules();
83
+ // Spied, so a test can tell whether the footer loaded the mock below or the
84
+ // real module: `doMock` applies only to imports after the reset, so a module
85
+ // graph that survived the collect-time warm-up would mean every assertion in
86
+ // this file was reading the developer's own repository instead.
87
+ const getGitStatus = vi.fn(() => ({ staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 }));
88
+ vi.doMock("./utils/git.js", () => ({
89
+ getGitStatus,
90
+ isInsideLinkedWorktree: () => false,
91
+ }));
92
+ vi.doMock("./utils/jj.js", () => ({ getJjBookmark: () => null, getJjChangedFiles: () => 0 }));
93
+ // Pin the footer's own settings to the shipped defaults. `session_start` calls
94
+ // `loadFooterConfig()`, which reads the developer's `settings.json`, so a saved
95
+ // `archimedes.footer.branchMaxLength` of 1–3 truncates the branch the fixtures
96
+ // assert on and a small `splitThreshold` repaginates the line: with
97
+ // `branchMaxLength: 2` set, three tests here fail on a footer that works
98
+ // perfectly. Mocked against the real defaults, so the numbers below are the
99
+ // ones the test claims rather than the ones a laptop happens to hold.
100
+ vi.doMock("./config.js", async (importOriginal) => {
101
+ const real = await importOriginal<typeof import("./config.js")>();
102
+ return { ...real, loadFooterConfig: () => real.DEFAULT_FOOTER_CONFIG };
103
+ });
104
+
105
+ const diagCalls: Array<[string, string]> = [];
106
+ // Partial mock: the sink is fake (that is what the assertions read), while
107
+ // `describeError` stays the real one, so a stack assertion here tests the
108
+ // footer's call site rather than a copy of the formatter.
109
+ vi.doMock("@pi-archimedes/core/log", async (importOriginal) => {
110
+ const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
111
+ return {
112
+ ...real,
113
+ diag: (scope: string, message: string) => {
114
+ diagCalls.push([scope, message]);
115
+ },
116
+ };
117
+ });
118
+
119
+ const { registerFooter } = await import("./index.js");
120
+ const { notifyVcsChange } = await import("./utils/vcs-change-events.js");
121
+
122
+ const handlers: Record<string, (event: unknown, ctx: ExtensionContext) => void> = {};
123
+ const pi = {
124
+ on: (event: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
125
+ handlers[event] = handler;
126
+ return () => {};
127
+ },
128
+ getThinkingLevel: () => "off",
129
+ } as unknown as ExtensionAPI;
130
+
131
+ let installed: FooterFactory | undefined;
132
+ const userNotice = vi.fn(() => {
133
+ if (options.brokenNotify) throw new Error("stale UI");
134
+ });
135
+ const ctx = {
136
+ hasUI: true,
137
+ model: undefined,
138
+ ui: {
139
+ setFooter: (f: FooterFactory) => {
140
+ installed = f;
141
+ },
142
+ notify: userNotice,
143
+ },
144
+ sessionManager: {
145
+ getEntries: () => {
146
+ if (options.failStats) throw new Error("session store exploded");
147
+ return [];
148
+ },
149
+ getSessionId: () => "s",
150
+ getLeafId: () => null,
151
+ },
152
+ getContextUsage: () => undefined,
153
+ } as unknown as ExtensionContext;
154
+
155
+ registerFooter(pi);
156
+ handlers["session_start"]!({}, ctx);
157
+ if (!installed) throw new Error("footer factory was never installed");
158
+ // Wrap the installed factory so teardown can dispose whatever a test builds
159
+ // (see `builtFooters`); the tests themselves are unchanged by the wrapper.
160
+ const factory: FooterFactory = (tui, theme, footerData) => {
161
+ const footer = installed!(tui, theme, footerData);
162
+ builtFooters.push(footer);
163
+ return footer;
164
+ };
165
+ return {
166
+ factory,
167
+ notify: notifyVcsChange,
168
+ shutdown: () => handlers["session_shutdown"]!({}, ctx),
169
+ userNotice,
170
+ gitStatus: getGitStatus,
171
+ diagCalls,
172
+ };
173
+ }
174
+
175
+ describe("footer repaint wiring", () => {
176
+ it("requests a render when a background VCS refresh reports a change", async () => {
177
+ const { factory, notify } = await loadFooter();
178
+ const tui = { requestRender: vi.fn() };
179
+ factory(tui, THEME, makeFooterData());
180
+
181
+ notify();
182
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
183
+ });
184
+
185
+ it("stops requesting renders once the footer is disposed", async () => {
186
+ const { factory, notify } = await loadFooter();
187
+ const tui = { requestRender: vi.fn() };
188
+ const footer = factory(tui, THEME, makeFooterData());
189
+
190
+ notify();
191
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
192
+
193
+ footer.dispose();
194
+ notify();
195
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
196
+ });
197
+
198
+ it("releases the host's branch-change subscription when the footer is disposed", async () => {
199
+ // `dispose()` has two subscriptions to hand back. The VCS-change one is
200
+ // observable through `requestRender`, which is how the tests above cover it.
201
+ // The host's branch-change callback is not observable from here, so nothing
202
+ // read it: deleting `unsubscribe()` from `dispose()` left every test in this
203
+ // file green (measured: 125/125 in the package) while a disposed footer
204
+ // stayed subscribed to the host and kept repainting a screen nobody shows.
205
+ // This test is what makes that mutation illegal.
206
+ const branchUnsubscribe = vi.fn();
207
+ const { factory } = await loadFooter();
208
+ const footer = factory({ requestRender: vi.fn() }, THEME, {
209
+ onBranchChange: () => branchUnsubscribe,
210
+ getGitBranch: () => "main",
211
+ getExtensionStatuses: () => new Map<string, string>(),
212
+ });
213
+
214
+ expect(branchUnsubscribe).not.toHaveBeenCalled();
215
+ footer.dispose();
216
+ expect(branchUnsubscribe).toHaveBeenCalledTimes(1);
217
+ });
218
+
219
+ it("repaints only the newest footer when the footer is rebuilt without disposal", async () => {
220
+ // A /reload or a second setFooter() call can install a new footer without
221
+ // disposing the old one; a listener left on the previous TUI would paint a
222
+ // dead screen (and accumulate one per reload).
223
+ const { factory, notify } = await loadFooter();
224
+ const staleTui = { requestRender: vi.fn() };
225
+ factory(staleTui, THEME, makeFooterData());
226
+
227
+ const liveTui = { requestRender: vi.fn() };
228
+ factory(liveTui, THEME, makeFooterData());
229
+
230
+ notify();
231
+ expect(liveTui.requestRender).toHaveBeenCalledTimes(1);
232
+ expect(staleTui.requestRender).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it("gives every fixture its own module graph, so no subscription crosses tests", async () => {
236
+ // The graph is warmed at collect time and the registry is reset per
237
+ // `loadFooter()` call. Should the warm-up ever leave one graph shared, the
238
+ // second fixture's `notify()` would drive the FIRST module instance's
239
+ // listener set and a footer from an earlier fixture would repaint through
240
+ // it — the order coupling these tests are meant to be free of, and the one
241
+ // thing a collect-time import could plausibly break. So: assert the reset
242
+ // is still real, rather than trusting that it is.
243
+ const earlier = await loadFooter();
244
+ const earlierTui = { requestRender: vi.fn() };
245
+ earlier.factory(earlierTui, THEME, makeFooterData());
246
+ earlier.notify();
247
+ expect(earlierTui.requestRender).toHaveBeenCalledTimes(1);
248
+
249
+ const later = await loadFooter();
250
+ const laterTui = { requestRender: vi.fn() };
251
+ later.factory(laterTui, THEME, makeFooterData());
252
+
253
+ earlierTui.requestRender.mockClear();
254
+ later.notify();
255
+ expect(laterTui.requestRender).toHaveBeenCalledTimes(1);
256
+ expect(earlierTui.requestRender).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("paints a frame from the fixture's git mock, not from the machine's repository", async () => {
260
+ // Companion check to the one above, and about the fixture's own honesty:
261
+ // `vi.doMock` only reaches imports made after the reset, so a registry that
262
+ // survived the warm-up would swap the real `./utils/git.js` back in and
263
+ // every assertion in this file would quietly be reading the developer's
264
+ // working copy — including the two render tests below, which only ever
265
+ // exercise the FAILED render path. This asserts a real frame is painted and
266
+ // that the mock is the module the footer actually called.
267
+ const { factory, gitStatus } = await loadFooter();
268
+ const footer = factory({ requestRender: vi.fn() }, THEME, makeFooterData());
269
+
270
+ const lines = footer.render(200);
271
+ expect(lines.length).toBeGreaterThan(0);
272
+ expect(lines.join(" ")).toContain("main");
273
+ expect(gitStatus).toHaveBeenCalled();
274
+ });
275
+
276
+ it("stops requesting renders once session_shutdown fires", async () => {
277
+ // The session is over, so the subscription has to go with it — a listener
278
+ // left behind would outlive the session and repaint a dead footer.
279
+ const { factory, notify, shutdown } = await loadFooter();
280
+ const tui = { requestRender: vi.fn() };
281
+ factory(tui, THEME, makeFooterData());
282
+
283
+ notify();
284
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
285
+
286
+ shutdown();
287
+ notify();
288
+ expect(tui.requestRender).toHaveBeenCalledTimes(1);
289
+ });
290
+
291
+ it("keeps the live footer subscribed when a replaced footer is disposed afterwards", async () => {
292
+ // The stale footer is disposed after a newer footer already took the
293
+ // module-level handle, so its dispose() must release only its own
294
+ // subscription — releasing whatever the handle points at now would
295
+ // unsubscribe the live footer and freeze the screen on background refresh.
296
+ const { factory, notify } = await loadFooter();
297
+ const staleTui = { requestRender: vi.fn() };
298
+ const staleFooter = factory(staleTui, THEME, makeFooterData());
299
+
300
+ const liveTui = { requestRender: vi.fn() };
301
+ factory(liveTui, THEME, makeFooterData());
302
+
303
+ staleFooter.dispose();
304
+ notify();
305
+ expect(staleTui.requestRender).not.toHaveBeenCalled();
306
+ expect(liveTui.requestRender).toHaveBeenCalledTimes(1);
307
+ });
308
+
309
+ it("releases the live subscription on rebuild after a replaced footer was disposed", async () => {
310
+ // Same interleaving as above, then a further rebuild. The guard is what
311
+ // keeps the handle pointing at the live subscription, so the next footer
312
+ // can still release it; clear the handle too eagerly and the replaced
313
+ // footer leaks and keeps painting a screen nobody shows.
314
+ const { factory, notify } = await loadFooter();
315
+ const staleTui = { requestRender: vi.fn() };
316
+ const staleFooter = factory(staleTui, THEME, makeFooterData());
317
+
318
+ const midTui = { requestRender: vi.fn() };
319
+ factory(midTui, THEME, makeFooterData());
320
+
321
+ staleFooter.dispose();
322
+
323
+ const newestTui = { requestRender: vi.fn() };
324
+ factory(newestTui, THEME, makeFooterData());
325
+
326
+ notify();
327
+ expect(newestTui.requestRender).toHaveBeenCalledTimes(1);
328
+ expect(midTui.requestRender).not.toHaveBeenCalled();
329
+ expect(staleTui.requestRender).not.toHaveBeenCalled();
330
+ });
331
+ });
332
+
333
+ describe("footer render failure surface", () => {
334
+ it("diagnoses the failure to the log and tells the user exactly once", async () => {
335
+ // A failed render is a blank status bar — the user must be told — but
336
+ // `render()` runs on every frame, so the console AND a notify per frame are
337
+ // both unacceptable: detail to the log, one line to the user.
338
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
339
+ try {
340
+ const { factory, userNotice, diagCalls } = await loadFooter({ failStats: true });
341
+ const footer = factory({ requestRender: vi.fn() }, THEME, makeFooterData());
342
+
343
+ expect(footer.render(80)).toEqual([]);
344
+ expect(footer.render(80)).toEqual([]);
345
+
346
+ expect(consoleError).not.toHaveBeenCalled();
347
+ // One diagnosis per failed frame — the detail is what debugging needs.
348
+ expect(diagCalls.length).toBe(2);
349
+ expect(diagCalls[0]![0]).toBe("footer");
350
+ expect(diagCalls[0]![1]).toContain("session store exploded");
351
+ // …and it carries the origin, not just the message: `console.error(scope,
352
+ // err)` used to print the stack, and for a swallowed render failure the
353
+ // log is the only place that stack ever appears.
354
+ expect(diagCalls[0]![1]).toMatch(/\n\s+at\s+\S/);
355
+ expect(userNotice).toHaveBeenCalledTimes(1);
356
+ expect(userNotice.mock.calls[0]![0]).not.toContain("\n");
357
+ expect(userNotice.mock.calls[0]![1]).toBe("warning");
358
+ } finally {
359
+ consoleError.mockRestore();
360
+ }
361
+ });
362
+
363
+ it("still records the diagnosis when the UI cannot be told", async () => {
364
+ // After a session replacement the context can be stale, and a failed
365
+ // report must not turn into a second failure inside the render path.
366
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
367
+ try {
368
+ const { factory, diagCalls } = await loadFooter({ failStats: true, brokenNotify: true });
369
+ const footer = factory({ requestRender: vi.fn() }, THEME, makeFooterData());
370
+
371
+ expect(() => footer.render(80)).not.toThrow();
372
+ expect(footer.render(80)).toEqual([]);
373
+ expect(consoleError).not.toHaveBeenCalled();
374
+ expect(diagCalls.length).toBeGreaterThanOrEqual(1);
375
+ expect(diagCalls[0]![1]).toContain("session store exploded");
376
+ } finally {
377
+ consoleError.mockRestore();
378
+ }
379
+ });
380
+ });
package/src/index.ts CHANGED
@@ -7,74 +7,100 @@
7
7
  * - left sections alone overflow → three or more lines
8
8
  * - below splitThreshold (default 150) → at least two lines, as configured
9
9
  * - oversized extension statuses are word-wrapped into multiple chunks so no
10
- * content is ever truncated by clampLine
10
+ * content is ever truncated by truncateToWidth
11
+ *
12
+ * Ours vs pi's data (docs/features/footer.md):
13
+ * - pi's own data, read straight from the host: model (`ctx.model`), thinking
14
+ * (`pi.getThinkingLevel()`), tokens/cost and context (session entries and
15
+ * `ctx.getContextUsage()`), branch (`footerData.getGitBranch()`) and the
16
+ * `ctx.ui.setStatus()` texts (`footerData.getExtensionStatuses()`). pi's own
17
+ * FooterComponent renders the same numbers from the same sources.
18
+ * - ours: the jj bookmark and jj changed-file count, the linked-worktree
19
+ * marker, the git status counts, the context bar, and the packing that wraps
20
+ * instead of truncating.
11
21
  */
12
22
 
13
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
- import { visibleWidth } from "@earendil-works/pi-tui";
15
- import { clampLine } from "@pi-archimedes/core/text";
24
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
25
+ import { diag, describeError } from "@pi-archimedes/core/log";
16
26
  import { loadFooterConfig } from "./config.js";
17
- import { CostAccumulator } from "./cost-accumulator.js";
18
27
  import { getGitStatus, isInsideLinkedWorktree } from "./utils/git.js";
19
- import { getJjBookmark } from "./utils/jj.js";
20
- import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
28
+ import { getJjBookmark, getJjChangedFiles } from "./utils/jj.js";
29
+ import { onVcsChange } from "./utils/vcs-change-events.js";
30
+ import { getContextWindowInfo, getTokenUsageStats } from "./utils/stats.js";
21
31
  import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount, truncateBranch, wrapStatusToChunks } from "./utils/format.js";
22
32
  import { footerIcons } from "./utils/icons.js";
23
33
  import { packFooterLines, SEP_W, SEPARATOR } from "./utils/layout.js";
24
34
 
25
35
  export function registerFooter(pi: ExtensionAPI): void {
26
- // Module-level state for session lifecycle (shared between session_start and session_shutdown)
27
- let footerAccumulator: CostAccumulator | undefined;
36
+ // Subscription for the footer's lifetime; module-level so a footer rebuilt
37
+ // without an explicit dispose cannot leave a second listener behind.
38
+ let unsubscribeVcsChange: (() => void) | undefined;
28
39
 
29
40
  // session_shutdown handler (top-level to prevent accumulation on /reload)
30
41
  pi.on("session_shutdown", (_event, _ctx) => {
31
- if (footerAccumulator) {
32
- footerAccumulator.dispose();
33
- footerAccumulator.reset();
34
- footerAccumulator = undefined;
35
- }
42
+ unsubscribeVcsChange?.();
43
+ unsubscribeVcsChange = undefined;
36
44
  });
37
45
 
38
46
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
39
47
  const { splitThreshold, branchMaxLength } = loadFooterConfig();
40
-
41
- // Create cost accumulator for subagent costs
42
- footerAccumulator = new CostAccumulator();
43
- footerAccumulator.subscribe();
48
+ // A render failure is reported once per session, not once per frame: the
49
+ // footer paints on every frame, so an unguarded notice would spam.
50
+ let renderFailureReported = false;
44
51
 
45
52
  ctx.ui.setFooter((tui, theme, footerData) => {
46
53
  const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
47
54
 
55
+ // Git/jj facts are refreshed in the background so a render never blocks;
56
+ // when a reply changes what this footer shows, ask for a frame — pi draws
57
+ // one only on request, so otherwise the stale value stays on screen.
58
+ unsubscribeVcsChange?.();
59
+ const stopVcsChange = onVcsChange(() => tui.requestRender());
60
+ unsubscribeVcsChange = stopVcsChange;
61
+
48
62
  return {
49
- dispose: unsubscribe,
63
+ dispose: () => {
64
+ unsubscribe();
65
+ stopVcsChange();
66
+ if (unsubscribeVcsChange === stopVcsChange) unsubscribeVcsChange = undefined;
67
+ },
50
68
  invalidate() { },
51
69
  render(width: number): string[] {
52
70
  try {
53
71
  const colorize = (token: string, s: string) => theme.fg(token as any, s);
54
- const activeModel = ctx.model?.id || "no-model";
55
- const currentBranch = getJjBookmark() ?? footerData.getGitBranch();
72
+ const activeModel = ctx.model?.id || "no-model"; // pi's own session state
73
+ // --- ours: jj support and the worktree marker have no pi equivalent ---
74
+ const jjBookmark = getJjBookmark();
75
+ const currentBranch = jjBookmark ?? footerData.getGitBranch(); // branch itself is pi's
56
76
  const currentDirectory = process.cwd().split("/").pop() || process.cwd();
57
- const gitStatus = getGitStatus();
58
- const inWorktree = isInsideLinkedWorktree();
77
+ // jj repos never run git: jj's changed-file count fills the "unstaged" (~N) slot
78
+ const gitStatus = jjBookmark !== null
79
+ ? { staged: 0, unstaged: getJjChangedFiles(), untracked: 0, ahead: 0, behind: 0 }
80
+ : getGitStatus();
81
+ const inWorktree = jjBookmark === null && isInsideLinkedWorktree();
59
82
  const thinkingLevel = pi.getThinkingLevel();
60
83
 
61
- // Merge main agent stats with subagent stats from accumulator
62
- const mainStats = getTokenUsageStats(ctx);
63
- const acc = footerAccumulator;
64
- const mergedStats: TokenUsageStats = {
65
- totalInput: mainStats.totalInput + (acc?.inputTokens ?? 0),
66
- totalOutput: mainStats.totalOutput + (acc?.outputTokens ?? 0),
67
- totalCacheRead: mainStats.totalCacheRead + (acc?.cacheReadTokens ?? 0),
68
- totalCacheWrite: mainStats.totalCacheWrite + (acc?.cacheWriteTokens ?? 0),
69
- totalCost: mainStats.totalCost + (acc?.cost ?? 0),
70
- };
71
-
72
- const { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost } = mergedStats;
84
+ // One source of truth for usage: the session entries. They carry the
85
+ // main agent's assistant usage AND any tool result that reported
86
+ // usage — which is how a subagent's cost arrives now that the tool
87
+ // returns native `usage` (ADR 0028). There used to be a second,
88
+ // additive source here (a `COST_UPDATE` listener accumulating what
89
+ // subagents published); keeping it would have counted that money
90
+ // twice — see `packages/subagent/src/usage.ts`.
91
+ const {
92
+ totalInput,
93
+ totalOutput,
94
+ totalCacheRead,
95
+ totalCacheWrite,
96
+ totalCost,
97
+ } = getTokenUsageStats(ctx);
73
98
  const { percent: contextPercent, percentValue: contextPercentValue, windowSize: contextWindowSize } = getContextWindowInfo(ctx);
74
99
 
75
100
  // ── Sections ──────────────────────────────────────────────────
76
101
 
77
- const thinkingIndicatorStr = formatThinkingIndicator(thinkingLevel, colorize);
102
+ const thinkingIndicatorStr = formatThinkingIndicator(thinkingLevel, colorize); // pi's level, our glyph
103
+ // --- ours: pi's footer shows the branch name, never its state ---
78
104
  const gitStatusStr = formatGitStatusIndicators(gitStatus, colorize);
79
105
 
80
106
  const branchIcon = inWorktree ? footerIcons.worktree : footerIcons.branch;
@@ -138,11 +164,24 @@ export function registerFooter(pi: ExtensionAPI): void {
138
164
  line = line ? line + separator + contextBarStr : contextBarStr;
139
165
  }
140
166
  }
141
- return clampLine(line, width);
167
+ return truncateToWidth(line, width);
142
168
  })
143
169
  .filter((line) => line.length > 0);
144
170
  } catch (e) {
145
- console.error("[archimedes:footer] Render error:", e);
171
+ // The user has to learn the status bar went blank, but never from a
172
+ // console write: detail to the log on every frame, one line to the
173
+ // user, and only if there is a UI that can take it.
174
+ diag("footer", `[archimedes:footer] Render error: ${describeError(e)}`);
175
+ if (!renderFailureReported) {
176
+ renderFailureReported = true;
177
+ try {
178
+ if (ctx.hasUI) {
179
+ ctx.ui.notify("Archimedes footer failed to render, so the status bar is empty.", "warning");
180
+ }
181
+ } catch {
182
+ // A context too stale to notify must not fail the render twice.
183
+ }
184
+ }
146
185
  return [];
147
186
  }
148
187
  },