@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.
@@ -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
- it("registers /footer command and session_start handler", async () => {
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 a single-line footer with model, path, git, and extension statuses", async () => {
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: { input: 1000, output: 200, cost: { total: 0.05 } },
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.ui.setFooter.mock.calls[0][0] as (
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
- getGitBranch: () => "main",
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).toHaveLength(1);
189
- const line = lines[0];
190
- expect(line).toContain("Anthropic: Claude Sonnet 4 6");
191
- expect(line).toContain("proj");
192
- expect(line).toContain("main");
193
- expect(line).toContain("1.2k");
194
- expect(line).toContain("$0.05");
195
- expect(line).toContain("25.0%/200k");
196
- expect(line).toContain("🪨 caveman:full");
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 without right side when there's nothing to show", async () => {
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
- getGitBranch: () => null,
212
- getExtensionStatuses: () => new Map(),
213
- getAvailableProviderCount: () => 1,
214
- onBranchChange: () => () => {},
320
+ ...fakeFooterDataDefault,
321
+ getExtensionStatuses: () =>
322
+ new Map([
323
+ ["subagents", "2"],
324
+ ["caveman", "🪨 caveman:full"],
325
+ ]),
215
326
  };
216
- const component = factory({}, fakeTheme, fakeFooterData);
217
- const lines = component.render(80);
218
- expect(lines).toHaveLength(1);
219
- expect(lines[0]).not.toContain("$");
220
- expect(lines[0]).toContain("proj");
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 = { input: 0, output: 0, cost: 0 };
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 buildLine(
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 left = [
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 right: string[] = [];
65
- const tokenSegment = renderTokens(theme, icons, totalTokens);
66
- if (tokenSegment) right.push(tokenSegment);
67
- const costSegment = renderCost(theme, icons, usage.cost);
68
- if (costSegment) right.push(costSegment);
69
- const contextSegment = renderContext(theme, icons, contextUsage
70
- ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow }
71
- : null);
72
- if (contextSegment) right.push(contextSegment);
73
- for (const [, value] of statuses) {
74
- if (value) right.push(renderExtensionStatus(theme, value));
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 sep = ` ${theme.fg("border", icons.separator)} `;
78
- const leftStr = left.join(sep);
79
- const rightStr = right.join(sep);
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
- if (rightStr.length === 0) return truncateToWidth(leftStr, width);
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
- const padWidth = Math.max(1, width - visibleWidth(leftStr) - visibleWidth(rightStr));
84
- return truncateToWidth(leftStr + " ".repeat(padWidth) + rightStr, width);
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
- ctx.ui.setFooter((_tui, theme, footerData) => {
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 [buildLine(themeFn, icons, ctx, pi, footerData, width)];
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 ? `astro-footer: on (${useNerd ? "nerd" : "ascii"} icons)` : "astro-footer: off",
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
  }