@astrofoundry/pi-astro 0.12.2 → 0.13.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.
- package/README.md +1 -1
- package/extensions/astro-agents/index.test.ts +7 -1
- package/extensions/astro-agents/index.ts +41 -26
- package/extensions/astro-footer/README.md +49 -27
- package/extensions/astro-footer/format.test.ts +61 -1
- package/extensions/astro-footer/format.ts +48 -0
- package/extensions/astro-footer/git-status.test.ts +45 -0
- package/extensions/astro-footer/git-status.ts +82 -0
- package/extensions/astro-footer/icons.ts +24 -3
- package/extensions/astro-footer/index.test.ts +217 -45
- package/extensions/astro-footer/index.ts +181 -33
- package/extensions/astro-footer/segments.test.ts +116 -24
- package/extensions/astro-footer/segments.ts +76 -17
- package/extensions/astro-footer/settings.test.ts +85 -0
- package/extensions/astro-footer/settings.ts +43 -0
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ interface FakePi {
|
|
|
7
7
|
handlers: Record<string, Handler>;
|
|
8
8
|
commands: Map<string, { handler: CommandHandler; getArgumentCompletions?: (p: string) => unknown }>;
|
|
9
9
|
getThinkingLevel: ReturnType<typeof vi.fn>;
|
|
10
|
+
getSessionName: ReturnType<typeof vi.fn>;
|
|
10
11
|
on: (event: string, h: Handler) => void;
|
|
11
12
|
registerCommand: (
|
|
12
13
|
name: string,
|
|
@@ -18,13 +19,14 @@ interface FakePi {
|
|
|
18
19
|
) => void;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
function makePi(): FakePi {
|
|
22
|
+
function makePi(sessionName?: string): FakePi {
|
|
22
23
|
const handlers: Record<string, Handler> = {};
|
|
23
24
|
const commands = new Map<string, { handler: CommandHandler; getArgumentCompletions?: (p: string) => unknown }>();
|
|
24
25
|
return {
|
|
25
26
|
handlers,
|
|
26
27
|
commands,
|
|
27
28
|
getThinkingLevel: vi.fn(() => "medium"),
|
|
29
|
+
getSessionName: vi.fn(() => sessionName),
|
|
28
30
|
on: (event, h) => {
|
|
29
31
|
handlers[event] = h;
|
|
30
32
|
},
|
|
@@ -41,33 +43,47 @@ interface FakeCtx {
|
|
|
41
43
|
ui: {
|
|
42
44
|
notify: ReturnType<typeof vi.fn>;
|
|
43
45
|
setFooter: ReturnType<typeof vi.fn>;
|
|
46
|
+
setWidget: ReturnType<typeof vi.fn>;
|
|
44
47
|
};
|
|
45
48
|
cwd: string;
|
|
46
49
|
model: { id: string } | undefined;
|
|
50
|
+
modelRegistry: { isUsingOAuth: ReturnType<typeof vi.fn> };
|
|
47
51
|
sessionManager: { getBranch: () => unknown[] };
|
|
48
52
|
getContextUsage: () => { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
function makeCtx(overrides: Partial<FakeCtx> = {}): FakeCtx {
|
|
55
|
+
function makeCtx(overrides: Partial<FakeCtx> = {}, oauth = false): FakeCtx {
|
|
52
56
|
return {
|
|
53
57
|
ui: {
|
|
54
58
|
notify: vi.fn(),
|
|
55
59
|
setFooter: vi.fn(),
|
|
60
|
+
setWidget: vi.fn(),
|
|
56
61
|
},
|
|
57
62
|
cwd: "/Users/astro/proj",
|
|
58
63
|
model: { id: "anthropic/claude-sonnet-4-6" },
|
|
64
|
+
modelRegistry: { isUsingOAuth: vi.fn(() => oauth) },
|
|
59
65
|
sessionManager: { getBranch: () => [] },
|
|
60
66
|
getContextUsage: () => undefined,
|
|
61
67
|
...overrides,
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
const fakeTheme = { fg: (_role: string, text: string) => text };
|
|
72
|
+
|
|
73
|
+
const fakeFooterDataDefault = {
|
|
74
|
+
getGitBranch: () => "main",
|
|
75
|
+
getExtensionStatuses: () => new Map<string, string>(),
|
|
76
|
+
getAvailableProviderCount: () => 1,
|
|
77
|
+
onBranchChange: () => () => {},
|
|
78
|
+
};
|
|
79
|
+
|
|
65
80
|
describe("astro-footer extension", () => {
|
|
66
81
|
const origEnv = { ...process.env };
|
|
67
82
|
|
|
68
83
|
beforeEach(() => {
|
|
69
84
|
vi.resetModules();
|
|
70
85
|
delete process.env.ASTRO_FOOTER_NERD_FONTS;
|
|
86
|
+
delete process.env.ASTRO_FOOTER_PATH;
|
|
71
87
|
delete process.env.TERM_PROGRAM;
|
|
72
88
|
delete process.env.TERM;
|
|
73
89
|
delete process.env.LC_TERMINAL;
|
|
@@ -86,17 +102,78 @@ describe("astro-footer extension", () => {
|
|
|
86
102
|
return await import("./index.ts");
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
async function install(): Promise<{ pi: FakePi; mod: Awaited<ReturnType<typeof load>> }> {
|
|
105
|
+
async function install(sessionName?: string): Promise<{ pi: FakePi; mod: Awaited<ReturnType<typeof load>> }> {
|
|
90
106
|
const mod = await load();
|
|
91
|
-
const pi = makePi();
|
|
107
|
+
const pi = makePi(sessionName);
|
|
92
108
|
mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
|
|
93
109
|
return { pi, mod };
|
|
94
110
|
}
|
|
95
111
|
|
|
96
|
-
|
|
112
|
+
function getFactory(ctx: FakeCtx): (tui: unknown, theme: unknown, footerData: unknown) => {
|
|
113
|
+
render: (w: number) => string[];
|
|
114
|
+
dispose?: () => void;
|
|
115
|
+
} {
|
|
116
|
+
return ctx.ui.setFooter.mock.calls[0][0] as (
|
|
117
|
+
tui: unknown,
|
|
118
|
+
theme: unknown,
|
|
119
|
+
footerData: unknown,
|
|
120
|
+
) => { render: (w: number) => string[]; dispose?: () => void };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it("registers /footer command and lifecycle handlers", async () => {
|
|
97
124
|
const { pi } = await install();
|
|
98
125
|
expect(pi.commands.has("footer")).toBe(true);
|
|
99
126
|
expect(pi.handlers.session_start).toBeDefined();
|
|
127
|
+
expect(pi.handlers.tool_result).toBeDefined();
|
|
128
|
+
expect(pi.handlers.before_agent_start).toBeDefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("registers a last-prompt widget below the editor on activate", async () => {
|
|
132
|
+
const { pi } = await install();
|
|
133
|
+
const ctx = makeCtx();
|
|
134
|
+
await pi.handlers.session_start({}, ctx);
|
|
135
|
+
const calls = ctx.ui.setWidget.mock.calls;
|
|
136
|
+
const lastPromptCall = calls.find((c) => c[0] === "astro-footer-last-prompt");
|
|
137
|
+
expect(lastPromptCall).toBeDefined();
|
|
138
|
+
expect(lastPromptCall![2]).toEqual({ placement: "belowEditor" });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("last-prompt widget renders nothing before the first prompt", async () => {
|
|
142
|
+
const { pi } = await install();
|
|
143
|
+
const ctx = makeCtx();
|
|
144
|
+
await pi.handlers.session_start({}, ctx);
|
|
145
|
+
const widgetCall = ctx.ui.setWidget.mock.calls.find((c) => c[0] === "astro-footer-last-prompt");
|
|
146
|
+
const factory = widgetCall![1] as (
|
|
147
|
+
tui: unknown,
|
|
148
|
+
theme: unknown,
|
|
149
|
+
) => { render: (w: number) => string[] };
|
|
150
|
+
const lines = factory({}, fakeTheme).render(120);
|
|
151
|
+
expect(lines).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("last-prompt widget renders the last submitted prompt with arrow prefix", async () => {
|
|
155
|
+
const { pi } = await install();
|
|
156
|
+
const ctx = makeCtx();
|
|
157
|
+
await pi.handlers.session_start({}, ctx);
|
|
158
|
+
await pi.handlers.before_agent_start({ prompt: "fix the auth middleware token expiry check" }, ctx);
|
|
159
|
+
const widgetCall = ctx.ui.setWidget.mock.calls.find((c) => c[0] === "astro-footer-last-prompt");
|
|
160
|
+
const factory = widgetCall![1] as (
|
|
161
|
+
tui: unknown,
|
|
162
|
+
theme: unknown,
|
|
163
|
+
) => { render: (w: number) => string[] };
|
|
164
|
+
const lines = factory({}, fakeTheme).render(120);
|
|
165
|
+
expect(lines).toHaveLength(1);
|
|
166
|
+
expect(lines[0]).toContain("↳");
|
|
167
|
+
expect(lines[0]).toContain("fix the auth middleware");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("/footer off clears the last-prompt widget too", async () => {
|
|
171
|
+
const { pi } = await install();
|
|
172
|
+
const ctx = makeCtx();
|
|
173
|
+
await pi.commands.get("footer")!.handler("off", ctx);
|
|
174
|
+
const widgetCalls = ctx.ui.setWidget.mock.calls;
|
|
175
|
+
const cleared = widgetCalls.find((c) => c[0] === "astro-footer-last-prompt" && c[1] === undefined);
|
|
176
|
+
expect(cleared).toBeDefined();
|
|
100
177
|
});
|
|
101
178
|
|
|
102
179
|
it("session_start activates the footer via ctx.ui.setFooter", async () => {
|
|
@@ -150,7 +227,7 @@ describe("astro-footer extension", () => {
|
|
|
150
227
|
);
|
|
151
228
|
});
|
|
152
229
|
|
|
153
|
-
it("renders
|
|
230
|
+
it("renders row 1 with model/path/git, tokens split, cost, ctx — and row 2 with statuses", async () => {
|
|
154
231
|
process.env.ASTRO_FOOTER_NERD_FONTS = "0";
|
|
155
232
|
const { pi } = await install();
|
|
156
233
|
const ctx = makeCtx({
|
|
@@ -160,7 +237,13 @@ describe("astro-footer extension", () => {
|
|
|
160
237
|
type: "message",
|
|
161
238
|
message: {
|
|
162
239
|
role: "assistant",
|
|
163
|
-
usage: {
|
|
240
|
+
usage: {
|
|
241
|
+
input: 1234,
|
|
242
|
+
output: 567,
|
|
243
|
+
cacheRead: 0,
|
|
244
|
+
cacheWrite: 0,
|
|
245
|
+
cost: { total: 0.05 },
|
|
246
|
+
},
|
|
164
247
|
},
|
|
165
248
|
},
|
|
166
249
|
],
|
|
@@ -168,55 +251,144 @@ describe("astro-footer extension", () => {
|
|
|
168
251
|
getContextUsage: () => ({ tokens: 50, contextWindow: 200_000, percent: 25 }),
|
|
169
252
|
});
|
|
170
253
|
await pi.handlers.session_start({}, ctx);
|
|
171
|
-
const factory = ctx
|
|
172
|
-
tui: unknown,
|
|
173
|
-
theme: unknown,
|
|
174
|
-
footerData: unknown,
|
|
175
|
-
) => { render: (w: number) => string[] };
|
|
176
|
-
const fakeTheme = {
|
|
177
|
-
fg: (_role: string, text: string) => text,
|
|
178
|
-
bold: (text: string) => text,
|
|
179
|
-
};
|
|
254
|
+
const factory = getFactory(ctx);
|
|
180
255
|
const fakeFooterData = {
|
|
181
|
-
|
|
256
|
+
...fakeFooterDataDefault,
|
|
182
257
|
getExtensionStatuses: () => new Map([["caveman", "🪨 caveman:full"]]),
|
|
183
|
-
getAvailableProviderCount: () => 1,
|
|
184
|
-
onBranchChange: () => () => {},
|
|
185
258
|
};
|
|
186
|
-
const component = factory({}, fakeTheme, fakeFooterData);
|
|
259
|
+
const component = factory({ requestRender: () => {} }, fakeTheme, fakeFooterData);
|
|
187
260
|
const lines = component.render(200);
|
|
188
|
-
expect(lines).
|
|
189
|
-
const
|
|
190
|
-
expect(
|
|
191
|
-
expect(
|
|
192
|
-
expect(
|
|
193
|
-
expect(
|
|
194
|
-
expect(
|
|
195
|
-
expect(
|
|
196
|
-
expect(
|
|
261
|
+
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
262
|
+
const row1 = lines[0];
|
|
263
|
+
expect(row1).toContain("Anthropic: Claude Sonnet 4 6");
|
|
264
|
+
expect(row1).toContain("proj");
|
|
265
|
+
expect(row1).toContain("main");
|
|
266
|
+
expect(row1).toContain("↑");
|
|
267
|
+
expect(row1).toContain("1.2k");
|
|
268
|
+
expect(row1).toContain("↓");
|
|
269
|
+
expect(row1).toContain("567");
|
|
270
|
+
expect(row1).toContain("$0.05");
|
|
271
|
+
expect(row1).toContain("25.0%/200k");
|
|
272
|
+
expect(lines.length).toBe(2);
|
|
273
|
+
expect(lines[1]).toContain("🪨 caveman:full");
|
|
274
|
+
component.dispose?.();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("OAuth-authed model shows (sub) instead of dollar cost", async () => {
|
|
278
|
+
process.env.ASTRO_FOOTER_NERD_FONTS = "0";
|
|
279
|
+
const { pi } = await install();
|
|
280
|
+
const ctx = makeCtx(
|
|
281
|
+
{
|
|
282
|
+
sessionManager: {
|
|
283
|
+
getBranch: () => [
|
|
284
|
+
{
|
|
285
|
+
type: "message",
|
|
286
|
+
message: {
|
|
287
|
+
role: "assistant",
|
|
288
|
+
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } },
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
true,
|
|
295
|
+
);
|
|
296
|
+
await pi.handlers.session_start({}, ctx);
|
|
297
|
+
const component = getFactory(ctx)({ requestRender: () => {} }, fakeTheme, fakeFooterDataDefault);
|
|
298
|
+
const row1 = component.render(200)[0];
|
|
299
|
+
expect(row1).toContain("(sub)");
|
|
300
|
+
component.dispose?.();
|
|
197
301
|
});
|
|
198
302
|
|
|
199
|
-
it("renders
|
|
303
|
+
it("renders single line when row 2 is empty", async () => {
|
|
304
|
+
process.env.ASTRO_FOOTER_NERD_FONTS = "0";
|
|
305
|
+
const { pi } = await install();
|
|
306
|
+
const ctx = makeCtx();
|
|
307
|
+
await pi.handlers.session_start({}, ctx);
|
|
308
|
+
const component = getFactory(ctx)({ requestRender: () => {} }, fakeTheme, fakeFooterDataDefault);
|
|
309
|
+
const lines = component.render(200);
|
|
310
|
+
expect(lines.length).toBe(1);
|
|
311
|
+
component.dispose?.();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("subagent count from extension status appears on row 2 and is excluded from generic statuses", async () => {
|
|
200
315
|
process.env.ASTRO_FOOTER_NERD_FONTS = "0";
|
|
201
316
|
const { pi } = await install();
|
|
202
317
|
const ctx = makeCtx();
|
|
203
318
|
await pi.handlers.session_start({}, ctx);
|
|
204
|
-
const factory = ctx.ui.setFooter.mock.calls[0][0] as (
|
|
205
|
-
tui: unknown,
|
|
206
|
-
theme: unknown,
|
|
207
|
-
footerData: unknown,
|
|
208
|
-
) => { render: (w: number) => string[] };
|
|
209
|
-
const fakeTheme = { fg: (_role: string, text: string) => text };
|
|
210
319
|
const fakeFooterData = {
|
|
211
|
-
|
|
212
|
-
getExtensionStatuses: () =>
|
|
213
|
-
|
|
214
|
-
|
|
320
|
+
...fakeFooterDataDefault,
|
|
321
|
+
getExtensionStatuses: () =>
|
|
322
|
+
new Map([
|
|
323
|
+
["subagents", "2"],
|
|
324
|
+
["caveman", "🪨 caveman:full"],
|
|
325
|
+
]),
|
|
215
326
|
};
|
|
216
|
-
const component =
|
|
217
|
-
const lines = component.render(
|
|
218
|
-
expect(lines).
|
|
219
|
-
|
|
220
|
-
expect(
|
|
327
|
+
const component = getFactory(ctx)({ requestRender: () => {} }, fakeTheme, fakeFooterData);
|
|
328
|
+
const lines = component.render(200);
|
|
329
|
+
expect(lines.length).toBe(2);
|
|
330
|
+
const row2 = lines[1];
|
|
331
|
+
expect(row2).toContain("2");
|
|
332
|
+
expect(row2).toContain("🪨 caveman:full");
|
|
333
|
+
expect(row2).not.toMatch(/\bsubagents\s*:\s*2\b/);
|
|
334
|
+
component.dispose?.();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("session name shows on row 2 when set", async () => {
|
|
338
|
+
process.env.ASTRO_FOOTER_NERD_FONTS = "0";
|
|
339
|
+
const { pi } = await install("before-rewrite");
|
|
340
|
+
const ctx = makeCtx();
|
|
341
|
+
await pi.handlers.session_start({}, ctx);
|
|
342
|
+
const component = getFactory(ctx)({ requestRender: () => {} }, fakeTheme, fakeFooterDataDefault);
|
|
343
|
+
const lines = component.render(200);
|
|
344
|
+
expect(lines.length).toBe(2);
|
|
345
|
+
expect(lines[1]).toContain("before-rewrite");
|
|
346
|
+
component.dispose?.();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("cache read/write totals show on row 2 when present", async () => {
|
|
350
|
+
process.env.ASTRO_FOOTER_NERD_FONTS = "0";
|
|
351
|
+
const { pi } = await install();
|
|
352
|
+
const ctx = makeCtx({
|
|
353
|
+
sessionManager: {
|
|
354
|
+
getBranch: () => [
|
|
355
|
+
{
|
|
356
|
+
type: "message",
|
|
357
|
+
message: {
|
|
358
|
+
role: "assistant",
|
|
359
|
+
usage: {
|
|
360
|
+
input: 100,
|
|
361
|
+
output: 50,
|
|
362
|
+
cacheRead: 8500,
|
|
363
|
+
cacheWrite: 2100,
|
|
364
|
+
cost: { total: 0.01 },
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
await pi.handlers.session_start({}, ctx);
|
|
372
|
+
const component = getFactory(ctx)({ requestRender: () => {} }, fakeTheme, fakeFooterDataDefault);
|
|
373
|
+
const lines = component.render(200);
|
|
374
|
+
expect(lines.length).toBe(2);
|
|
375
|
+
const row2 = lines[1];
|
|
376
|
+
expect(row2).toContain("cR");
|
|
377
|
+
expect(row2).toContain("8.5k");
|
|
378
|
+
expect(row2).toContain("cW");
|
|
379
|
+
expect(row2).toContain("2.1k");
|
|
380
|
+
component.dispose?.();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("ASTRO_FOOTER_PATH=full shows the full home-tilded path", async () => {
|
|
384
|
+
process.env.ASTRO_FOOTER_NERD_FONTS = "0";
|
|
385
|
+
process.env.ASTRO_FOOTER_PATH = "full";
|
|
386
|
+
const { pi } = await install();
|
|
387
|
+
const ctx = makeCtx({ cwd: `${process.env.HOME}/wb/github/astrofoundry/pi-astro` });
|
|
388
|
+
await pi.handlers.session_start({}, ctx);
|
|
389
|
+
const component = getFactory(ctx)({ requestRender: () => {} }, fakeTheme, fakeFooterDataDefault);
|
|
390
|
+
const row1 = component.render(200)[0];
|
|
391
|
+
expect(row1).toContain("~/wb/github/astrofoundry/pi-astro");
|
|
392
|
+
component.dispose?.();
|
|
221
393
|
});
|
|
222
394
|
});
|
|
@@ -4,9 +4,14 @@ import type {
|
|
|
4
4
|
ReadonlyFooterDataProvider,
|
|
5
5
|
} from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
7
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
8
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
9
|
+
import { resolvePathMode, type PathMode } from "./format.ts";
|
|
10
|
+
import { GitStatusTracker } from "./git-status.ts";
|
|
8
11
|
import { detectNerdFonts, iconsFor, type IconSet } from "./icons.ts";
|
|
12
|
+
import { isAutoCompactEnabled } from "./settings.ts";
|
|
9
13
|
import {
|
|
14
|
+
renderCache,
|
|
10
15
|
renderContext,
|
|
11
16
|
renderCost,
|
|
12
17
|
renderExtensionStatus,
|
|
@@ -14,19 +19,47 @@ import {
|
|
|
14
19
|
renderModel,
|
|
15
20
|
renderPath,
|
|
16
21
|
renderPi,
|
|
22
|
+
renderSessionName,
|
|
23
|
+
renderSessionTime,
|
|
24
|
+
renderSubagents,
|
|
17
25
|
renderThinking,
|
|
18
26
|
renderTokens,
|
|
19
27
|
type ThemeFn,
|
|
20
28
|
} from "./segments.ts";
|
|
21
29
|
|
|
30
|
+
const SUBAGENT_STATUS_KEY = "subagents";
|
|
31
|
+
const LAST_PROMPT_WIDGET_KEY = "astro-footer-last-prompt";
|
|
32
|
+
|
|
33
|
+
function renderLastPromptLine(theme: Theme, lastPrompt: string, width: number): string[] {
|
|
34
|
+
const prefix = ` ${theme.fg("dim", "↳")} `;
|
|
35
|
+
const available = width - visibleWidth(prefix);
|
|
36
|
+
if (available < 10) return [];
|
|
37
|
+
const collapsed = lastPrompt.replace(/\s+/g, " ").trim();
|
|
38
|
+
if (collapsed.length === 0) return [];
|
|
39
|
+
const truncated = truncateToWidth(collapsed, available, "…");
|
|
40
|
+
return [truncateToWidth(`${prefix}${theme.fg("dim", truncated)}`, width, "…")];
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
interface UsageTotals {
|
|
23
44
|
input: number;
|
|
24
45
|
output: number;
|
|
25
46
|
cost: number;
|
|
47
|
+
cacheRead: number;
|
|
48
|
+
cacheWrite: number;
|
|
49
|
+
paidTurns: number;
|
|
50
|
+
totalTurns: number;
|
|
26
51
|
}
|
|
27
52
|
|
|
28
53
|
function collectUsage(ctx: ExtensionContext): UsageTotals {
|
|
29
|
-
const totals: UsageTotals = {
|
|
54
|
+
const totals: UsageTotals = {
|
|
55
|
+
input: 0,
|
|
56
|
+
output: 0,
|
|
57
|
+
cost: 0,
|
|
58
|
+
cacheRead: 0,
|
|
59
|
+
cacheWrite: 0,
|
|
60
|
+
paidTurns: 0,
|
|
61
|
+
totalTurns: 0,
|
|
62
|
+
};
|
|
30
63
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
31
64
|
if (entry.type !== "message") continue;
|
|
32
65
|
if (entry.message.role !== "assistant") continue;
|
|
@@ -34,80 +67,194 @@ function collectUsage(ctx: ExtensionContext): UsageTotals {
|
|
|
34
67
|
totals.input += msg.usage.input;
|
|
35
68
|
totals.output += msg.usage.output;
|
|
36
69
|
totals.cost += msg.usage.cost.total;
|
|
70
|
+
totals.cacheRead += msg.usage.cacheRead;
|
|
71
|
+
totals.cacheWrite += msg.usage.cacheWrite;
|
|
72
|
+
totals.totalTurns++;
|
|
73
|
+
if (msg.usage.cost.total > 0) totals.paidTurns++;
|
|
37
74
|
}
|
|
38
75
|
return totals;
|
|
39
76
|
}
|
|
40
77
|
|
|
41
|
-
function
|
|
78
|
+
function readSubagentCount(footerData: ReadonlyFooterDataProvider): number {
|
|
79
|
+
const value = footerData.getExtensionStatuses().get(SUBAGENT_STATUS_KEY);
|
|
80
|
+
if (!value) return 0;
|
|
81
|
+
const parsed = Number.parseInt(value, 10);
|
|
82
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildLines(
|
|
42
86
|
theme: ThemeFn,
|
|
43
87
|
icons: IconSet,
|
|
44
88
|
ctx: ExtensionContext,
|
|
45
89
|
pi: ExtensionAPI,
|
|
46
90
|
footerData: ReadonlyFooterDataProvider,
|
|
91
|
+
gitTracker: GitStatusTracker,
|
|
92
|
+
pathMode: PathMode,
|
|
93
|
+
sessionStartedAt: number,
|
|
94
|
+
autoCompact: boolean,
|
|
47
95
|
width: number,
|
|
48
|
-
): string {
|
|
96
|
+
): string[] {
|
|
49
97
|
const usage = collectUsage(ctx);
|
|
50
|
-
const totalTokens = usage.input + usage.output;
|
|
51
98
|
const contextUsage = ctx.getContextUsage();
|
|
52
99
|
const thinkingLevel = pi.getThinkingLevel();
|
|
53
100
|
const branch = footerData.getGitBranch();
|
|
101
|
+
const gitCounts = gitTracker.get();
|
|
54
102
|
const statuses = footerData.getExtensionStatuses();
|
|
103
|
+
const subagentCount = readSubagentCount(footerData);
|
|
104
|
+
const sessionName = pi.getSessionName();
|
|
105
|
+
const usingSubscription =
|
|
106
|
+
ctx.model !== undefined && ctx.modelRegistry.isUsingOAuth(ctx.model) && usage.totalTurns > 0;
|
|
107
|
+
|
|
108
|
+
const sep = ` ${theme.fg("border", icons.separator)} `;
|
|
55
109
|
|
|
56
|
-
const
|
|
110
|
+
const row1Left = [
|
|
57
111
|
renderPi(theme, icons),
|
|
58
112
|
renderModel(theme, icons, ctx.model?.id),
|
|
59
113
|
renderThinking(theme, icons, thinkingLevel),
|
|
60
|
-
renderPath(theme, icons, ctx.cwd),
|
|
61
|
-
renderGit(theme, icons, branch),
|
|
114
|
+
renderPath(theme, icons, ctx.cwd, pathMode),
|
|
115
|
+
renderGit(theme, icons, branch, gitCounts),
|
|
62
116
|
].filter((s): s is string => Boolean(s));
|
|
63
117
|
|
|
64
|
-
const
|
|
65
|
-
const tokenSegment = renderTokens(theme, icons,
|
|
66
|
-
if (tokenSegment)
|
|
67
|
-
const costSegment = renderCost(theme, icons, usage.cost);
|
|
68
|
-
if (costSegment)
|
|
69
|
-
const contextSegment = renderContext(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
const row1Right: string[] = [];
|
|
119
|
+
const tokenSegment = renderTokens(theme, icons, usage.input, usage.output);
|
|
120
|
+
if (tokenSegment) row1Right.push(tokenSegment);
|
|
121
|
+
const costSegment = renderCost(theme, icons, { costUsd: usage.cost, isSubscription: usingSubscription });
|
|
122
|
+
if (costSegment) row1Right.push(costSegment);
|
|
123
|
+
const contextSegment = renderContext(
|
|
124
|
+
theme,
|
|
125
|
+
icons,
|
|
126
|
+
contextUsage
|
|
127
|
+
? {
|
|
128
|
+
percent: contextUsage.percent,
|
|
129
|
+
contextWindow: contextUsage.contextWindow,
|
|
130
|
+
autoCompact,
|
|
131
|
+
}
|
|
132
|
+
: null,
|
|
133
|
+
);
|
|
134
|
+
if (contextSegment) row1Right.push(contextSegment);
|
|
76
135
|
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
136
|
+
const row2Items: string[] = [];
|
|
137
|
+
const subagentSegment = renderSubagents(theme, icons, subagentCount);
|
|
138
|
+
if (subagentSegment) row2Items.push(subagentSegment);
|
|
139
|
+
const elapsedMs = Math.max(0, Date.now() - sessionStartedAt);
|
|
140
|
+
const sessionTimeSegment = renderSessionTime(theme, icons, elapsedMs);
|
|
141
|
+
if (sessionTimeSegment) row2Items.push(sessionTimeSegment);
|
|
142
|
+
const sessionNameSegment = renderSessionName(theme, icons, sessionName);
|
|
143
|
+
if (sessionNameSegment) row2Items.push(sessionNameSegment);
|
|
144
|
+
const cacheSegment = renderCache(theme, icons, usage.cacheRead, usage.cacheWrite);
|
|
145
|
+
if (cacheSegment) row2Items.push(cacheSegment);
|
|
146
|
+
for (const [key, value] of statuses) {
|
|
147
|
+
if (!value || key === SUBAGENT_STATUS_KEY) continue;
|
|
148
|
+
row2Items.push(renderExtensionStatus(theme, value));
|
|
149
|
+
}
|
|
80
150
|
|
|
81
|
-
|
|
151
|
+
const row1Left2 = row1Left.join(sep);
|
|
152
|
+
const row1Right2 = row1Right.join(sep);
|
|
153
|
+
let row1: string;
|
|
154
|
+
if (row1Right2.length === 0) {
|
|
155
|
+
row1 = truncateToWidth(row1Left2, width);
|
|
156
|
+
} else {
|
|
157
|
+
const padWidth = Math.max(1, width - visibleWidth(row1Left2) - visibleWidth(row1Right2));
|
|
158
|
+
row1 = truncateToWidth(row1Left2 + " ".repeat(padWidth) + row1Right2, width);
|
|
159
|
+
}
|
|
82
160
|
|
|
83
|
-
|
|
84
|
-
|
|
161
|
+
if (row2Items.length === 0) return [row1];
|
|
162
|
+
const row2 = truncateToWidth(row2Items.join(sep), width);
|
|
163
|
+
return [row1, row2];
|
|
85
164
|
}
|
|
86
165
|
|
|
166
|
+
const REFRESH_INTERVAL_MS = 30_000;
|
|
167
|
+
|
|
87
168
|
export default function astroFooterExtension(pi: ExtensionAPI): void {
|
|
88
169
|
const useNerd = detectNerdFonts();
|
|
89
170
|
const icons = iconsFor(useNerd);
|
|
90
|
-
|
|
171
|
+
const pathMode = resolvePathMode();
|
|
91
172
|
let enabled = true;
|
|
173
|
+
let sessionStartedAt = Date.now();
|
|
174
|
+
let autoCompact = true;
|
|
175
|
+
let activeRequestRender: (() => void) | null = null;
|
|
176
|
+
let timeTickHandle: ReturnType<typeof setInterval> | null = null;
|
|
177
|
+
let lastPrompt = "";
|
|
178
|
+
|
|
179
|
+
const gitTracker = new GitStatusTracker(() => {
|
|
180
|
+
activeRequestRender?.();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function startTimeTicker(): void {
|
|
184
|
+
if (timeTickHandle !== null) return;
|
|
185
|
+
timeTickHandle = setInterval(() => {
|
|
186
|
+
activeRequestRender?.();
|
|
187
|
+
}, REFRESH_INTERVAL_MS);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function stopTimeTicker(): void {
|
|
191
|
+
if (timeTickHandle === null) return;
|
|
192
|
+
clearInterval(timeTickHandle);
|
|
193
|
+
timeTickHandle = null;
|
|
194
|
+
}
|
|
92
195
|
|
|
93
196
|
function activate(ctx: ExtensionContext): void {
|
|
94
|
-
|
|
197
|
+
sessionStartedAt = Date.now();
|
|
198
|
+
autoCompact = isAutoCompactEnabled(ctx.cwd);
|
|
199
|
+
gitTracker.refresh(ctx.cwd, true);
|
|
200
|
+
ctx.ui.setWidget(
|
|
201
|
+
LAST_PROMPT_WIDGET_KEY,
|
|
202
|
+
(_tui, theme) => ({
|
|
203
|
+
invalidate() {},
|
|
204
|
+
render(width: number): string[] {
|
|
205
|
+
if (!lastPrompt) return [];
|
|
206
|
+
return renderLastPromptLine(theme, lastPrompt, width);
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
{ placement: "belowEditor" },
|
|
210
|
+
);
|
|
211
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
212
|
+
activeRequestRender = () => tui.requestRender();
|
|
213
|
+
startTimeTicker();
|
|
95
214
|
const themeFn: ThemeFn = {
|
|
96
215
|
fg: (role, text) => theme.fg(role, text),
|
|
97
216
|
};
|
|
98
217
|
return {
|
|
99
218
|
invalidate() {},
|
|
219
|
+
dispose() {
|
|
220
|
+
stopTimeTicker();
|
|
221
|
+
activeRequestRender = null;
|
|
222
|
+
},
|
|
100
223
|
render(width: number): string[] {
|
|
101
|
-
return
|
|
224
|
+
return buildLines(
|
|
225
|
+
themeFn,
|
|
226
|
+
icons,
|
|
227
|
+
ctx,
|
|
228
|
+
pi,
|
|
229
|
+
footerData,
|
|
230
|
+
gitTracker,
|
|
231
|
+
pathMode,
|
|
232
|
+
sessionStartedAt,
|
|
233
|
+
autoCompact,
|
|
234
|
+
width,
|
|
235
|
+
);
|
|
102
236
|
},
|
|
103
237
|
};
|
|
104
238
|
});
|
|
105
239
|
}
|
|
106
240
|
|
|
107
241
|
pi.on("session_start", async (_event, ctx) => {
|
|
242
|
+
lastPrompt = "";
|
|
108
243
|
if (enabled) activate(ctx);
|
|
109
244
|
});
|
|
110
245
|
|
|
246
|
+
pi.on("before_agent_start", async (event) => {
|
|
247
|
+
lastPrompt = event.prompt ?? "";
|
|
248
|
+
activeRequestRender?.();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
252
|
+
if (!enabled) return;
|
|
253
|
+
if (event.toolName === "edit" || event.toolName === "write" || event.toolName === "bash") {
|
|
254
|
+
gitTracker.refresh(ctx.cwd);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
111
258
|
pi.registerCommand("footer", {
|
|
112
259
|
description: "Toggle astro-footer. Usage: /footer [on|off|status].",
|
|
113
260
|
getArgumentCompletions: (prefix) => {
|
|
@@ -122,7 +269,9 @@ export default function astroFooterExtension(pi: ExtensionAPI): void {
|
|
|
122
269
|
const sub = args.trim().toLowerCase() || "status";
|
|
123
270
|
if (sub === "status") {
|
|
124
271
|
ctx.ui.notify(
|
|
125
|
-
enabled
|
|
272
|
+
enabled
|
|
273
|
+
? `astro-footer: on (${useNerd ? "nerd" : "ascii"} icons, path=${pathMode})`
|
|
274
|
+
: "astro-footer: off",
|
|
126
275
|
"info",
|
|
127
276
|
);
|
|
128
277
|
return;
|
|
@@ -135,14 +284,13 @@ export default function astroFooterExtension(pi: ExtensionAPI): void {
|
|
|
135
284
|
}
|
|
136
285
|
if (sub === "off") {
|
|
137
286
|
enabled = false;
|
|
287
|
+
stopTimeTicker();
|
|
138
288
|
ctx.ui.setFooter(undefined);
|
|
289
|
+
ctx.ui.setWidget(LAST_PROMPT_WIDGET_KEY, undefined);
|
|
139
290
|
ctx.ui.notify("astro-footer: off (default footer restored)", "info");
|
|
140
291
|
return;
|
|
141
292
|
}
|
|
142
|
-
ctx.ui.notify(
|
|
143
|
-
`astro-footer: unknown subcommand "${sub}". Use on|off|status.`,
|
|
144
|
-
"warning",
|
|
145
|
-
);
|
|
293
|
+
ctx.ui.notify(`astro-footer: unknown subcommand "${sub}". Use on|off|status.`, "warning");
|
|
146
294
|
},
|
|
147
295
|
});
|
|
148
296
|
}
|