@pi-archimedes/footer 2.10.4 → 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
|
@@ -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
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
42
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
const
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
167
|
+
return truncateToWidth(line, width);
|
|
142
168
|
})
|
|
143
169
|
.filter((line) => line.length > 0);
|
|
144
170
|
} catch (e) {
|
|
145
|
-
|
|
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
|
},
|