@astrofoundry/pi-astro 0.10.0 → 0.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 CHANGED
@@ -66,6 +66,7 @@ pi # launch; confirm [Extensions] lists astro-agents, claude-glob
66
66
  - **macOS voice install (required once for the default):** open **System Settings → Accessibility → Spoken Content → System Voice → Manage Voices…**, expand **English**, check **Samantha (Enhanced)**, click **Done** to download (~500 MB – 1 GB). Verify with `say -v "Samantha (Enhanced)" hi`. If the voice is missing, `say` errors silently and you'll only hear the Glass sound.
67
67
  - `vscode-image` — only active inside VS Code's integrated terminal (`TERM_PROGRAM=vscode`). Switches pi-tui's image output to the **Kitty graphics protocol** so images returned by tools like `gemini_image` render as real pixels instead of the `[Image: …]` text fallback. **Requires enabling `Terminal › Integrated: Enable Images` in VS Code settings** (off by default); restart the integrated terminal after flipping it. Disable with `PI_VSCODE_IMAGE_OFF=1`. `/vscode-image` prints current status.
68
68
  - `claude-globals` — auto-injects `~/.claude/CLAUDE.md` into every pi session's system prompt
69
+ - `astro-footer` — single-line powerline-style footer: pi marker, model, thinking level, cwd basename, git branch, total tokens, cost, context %, plus any extension statuses (`ctx.ui.setStatus`). Auto-detects Nerd Fonts (iTerm/WezTerm/Kitty/Ghostty/Alacritty) with ASCII fallback. Override via `ASTRO_FOOTER_NERD_FONTS=0|1`. Toggle with `/footer [on|off|status]`. See [extensions/astro-footer/README.md](extensions/astro-footer/README.md).
69
70
  - `caveman` — `/caveman [lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off|status]` toggles a persistent compressed-output mode. No argument toggles between off and the default level (`full`). Active level is shown as a footer badge and survives `/reload`. The skill body at `skills/caveman/SKILL.md` is also available as a one-shot via `/skill:caveman`.
70
71
 
71
72
  **Bundled subagents** (callable via `astro_agent`):
@@ -73,7 +73,7 @@ export default function astroAgentsExtension(pi: ExtensionAPI): void {
73
73
  description:
74
74
  "Delegate a task to a named subagent in an isolated pi subprocess. Agents are discovered from bundled (astro-agents/agents/), user-level (~/.pi/agent/agents/), and project-local (.pi/agents/) sources.",
75
75
  parameters: params,
76
- async execute(_toolCallId, input, signal, _onUpdate, ctx) {
76
+ async execute(_toolCallId, input, signal, onUpdate, ctx) {
77
77
  const scope: AgentScope = input.scope ?? "user";
78
78
 
79
79
  const agents = discoverAgents({
@@ -102,12 +102,26 @@ export default function astroAgentsExtension(pi: ExtensionAPI): void {
102
102
 
103
103
  const skillPaths = agent.skills ? resolvePackageSkills(agent.skills, ctx) : [];
104
104
 
105
+ const detailsBase = {
106
+ agent: agent.name,
107
+ source: agent.source,
108
+ color: agent.color,
109
+ };
110
+
105
111
  const result = await runAgent({
106
112
  agent,
107
113
  task: input.task,
108
114
  cwd: ctx.cwd,
109
115
  skillPaths,
110
116
  signal,
117
+ onUpdate: onUpdate
118
+ ? (text) => {
119
+ onUpdate({
120
+ content: [{ type: "text", text }],
121
+ details: detailsBase,
122
+ });
123
+ }
124
+ : undefined,
111
125
  });
112
126
 
113
127
  if (result.exitCode !== 0) {
@@ -120,11 +134,7 @@ export default function astroAgentsExtension(pi: ExtensionAPI): void {
120
134
 
121
135
  return {
122
136
  content: [{ type: "text", text }],
123
- details: {
124
- agent: agent.name,
125
- source: agent.source,
126
- color: agent.color,
127
- },
137
+ details: detailsBase,
128
138
  };
129
139
  },
130
140
  });
@@ -198,6 +198,103 @@ describe("runAgent (spawn.ts)", () => {
198
198
  expect(res.stderr).toContain("spawn failed");
199
199
  });
200
200
 
201
+ it("streams onUpdate after each message_end and uses agent_end as final state", async () => {
202
+ const { spawn } = await import("node:child_process");
203
+ const fake = makeFakeProc();
204
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
205
+ const { runAgent } = await import("./spawn.ts");
206
+
207
+ const updates: string[] = [];
208
+ const promise = runAgent({
209
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
210
+ task: "t",
211
+ cwd: "/tmp",
212
+ onUpdate: (text) => updates.push(text),
213
+ });
214
+
215
+ fake.emit(
216
+ "stdout",
217
+ JSON.stringify({
218
+ type: "message_end",
219
+ message: { role: "assistant", content: [{ type: "text", text: "first" }] },
220
+ }) + "\n",
221
+ );
222
+ fake.emit(
223
+ "stdout",
224
+ JSON.stringify({
225
+ type: "message_end",
226
+ message: { role: "assistant", content: [{ type: "text", text: "second" }] },
227
+ }) + "\n",
228
+ );
229
+ fake.close(0);
230
+
231
+ const res = await promise;
232
+ expect(updates).toEqual(["first", "second"]);
233
+ expect(res.text).toBe("second");
234
+ });
235
+
236
+ it("does not emit duplicate onUpdate when message text is unchanged", async () => {
237
+ const { spawn } = await import("node:child_process");
238
+ const fake = makeFakeProc();
239
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
240
+ const { runAgent } = await import("./spawn.ts");
241
+
242
+ const updates: string[] = [];
243
+ const promise = runAgent({
244
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
245
+ task: "t",
246
+ cwd: "/tmp",
247
+ onUpdate: (text) => updates.push(text),
248
+ });
249
+
250
+ fake.emit(
251
+ "stdout",
252
+ JSON.stringify({
253
+ type: "message_end",
254
+ message: { role: "assistant", content: [{ type: "text", text: "only-text" }] },
255
+ }) + "\n",
256
+ );
257
+ fake.emit(
258
+ "stdout",
259
+ JSON.stringify({
260
+ type: "message_end",
261
+ message: { role: "tool", content: [{ type: "text", text: "ignored-tool-result" }] },
262
+ }) + "\n",
263
+ );
264
+ fake.close(0);
265
+
266
+ await promise;
267
+ expect(updates).toEqual(["only-text"]);
268
+ });
269
+
270
+ it("falls back to agent_end messages when message_end events are absent", async () => {
271
+ const { spawn } = await import("node:child_process");
272
+ const fake = makeFakeProc();
273
+ vi.mocked(spawn).mockReturnValue(fake.proc as unknown as ReturnType<typeof spawn>);
274
+ const { runAgent } = await import("./spawn.ts");
275
+
276
+ const updates: string[] = [];
277
+ const promise = runAgent({
278
+ agent: { name: "a", description: "x", source: "bundle", filePath: "/tmp/a.md", systemPrompt: "" },
279
+ task: "t",
280
+ cwd: "/tmp",
281
+ onUpdate: (text) => updates.push(text),
282
+ });
283
+
284
+ fake.emit(
285
+ "stdout",
286
+ JSON.stringify({
287
+ type: "agent_end",
288
+ messages: [{ role: "assistant", content: [{ type: "text", text: "final" }] }],
289
+ }) + "\n",
290
+ );
291
+ fake.close(0);
292
+
293
+ const res = await promise;
294
+ expect(res.text).toBe("final");
295
+ expect(updates).toEqual(["final"]);
296
+ });
297
+
201
298
  it("kills process on abort signal", async () => {
202
299
  const { spawn } = await import("node:child_process");
203
300
  const fake = makeFakeProc();
@@ -19,6 +19,11 @@ interface AgentEndEvent {
19
19
  messages?: AssistantMessage[];
20
20
  }
21
21
 
22
+ interface MessageEndEvent {
23
+ type: "message_end";
24
+ message?: AssistantMessage;
25
+ }
26
+
22
27
  interface UnknownEvent {
23
28
  type?: string;
24
29
  [key: string]: unknown;
@@ -73,10 +78,11 @@ export interface RunAgentOptions {
73
78
  cwd: string;
74
79
  skillPaths?: string[];
75
80
  signal?: AbortSignal | undefined;
81
+ onUpdate?: (text: string) => void;
76
82
  }
77
83
 
78
84
  export async function runAgent(options: RunAgentOptions): Promise<SpawnResult> {
79
- const { agent, task, cwd, skillPaths, signal } = options;
85
+ const { agent, task, cwd, skillPaths, signal, onUpdate } = options;
80
86
 
81
87
  const args = ["--mode", "json", "-p", "--no-session"];
82
88
  let tmpDir: string | null = null;
@@ -112,6 +118,15 @@ export async function runAgent(options: RunAgentOptions): Promise<SpawnResult> {
112
118
  let stdoutBuffer = "";
113
119
  let stderrBuffer = "";
114
120
  const collectedMessages: AssistantMessage[] = [];
121
+ let lastEmittedText = "";
122
+
123
+ const emitProgress = (): void => {
124
+ if (!onUpdate) return;
125
+ const text = extractTextFromMessages(collectedMessages);
126
+ if (!text || text === lastEmittedText) return;
127
+ lastEmittedText = text;
128
+ onUpdate(text);
129
+ };
115
130
 
116
131
  const handleLine = (line: string): void => {
117
132
  if (!line.trim()) return;
@@ -121,9 +136,21 @@ export async function runAgent(options: RunAgentOptions): Promise<SpawnResult> {
121
136
  } catch {
122
137
  return;
123
138
  }
139
+ if (event.type === "message_end") {
140
+ const msg = (event as MessageEndEvent).message;
141
+ if (msg) {
142
+ collectedMessages.push(msg);
143
+ emitProgress();
144
+ }
145
+ return;
146
+ }
124
147
  if (event.type === "agent_end") {
125
148
  const end = event as AgentEndEvent;
126
- if (end.messages) collectedMessages.push(...end.messages);
149
+ if (end.messages && end.messages.length > collectedMessages.length) {
150
+ collectedMessages.length = 0;
151
+ collectedMessages.push(...end.messages);
152
+ emitProgress();
153
+ }
127
154
  }
128
155
  };
129
156
 
@@ -0,0 +1,63 @@
1
+ # astro-footer
2
+
3
+ Single-line powerline-style footer for the [pi coding agent](https://github.com/badlogic/pi-mono). Replaces the built-in footer with a compact status line:
4
+
5
+ ```
6
+ π › claude-sonnet-4-6 › think:med › pi-astro › main 1.2k › $0.05 › 25% › 🪨 caveman:full
7
+ ```
8
+
9
+ Ships as part of [`@astrofoundry/pi-astro`](../../README.md). No extra install. Auto-activates on every pi session.
10
+
11
+ ## What it shows
12
+
13
+ **Left side:**
14
+
15
+ | Segment | Value |
16
+ |---|---|
17
+ | `π` | Pi marker |
18
+ | **model** | Last segment of the active model id (e.g. `claude-sonnet-4-6`) |
19
+ | **thinking** | Current thinking level (`think:off` … `think:xhi`), colour-coded |
20
+ | **path** | Current working directory's basename |
21
+ | **git** | Current branch (hidden when not in a git repo) |
22
+
23
+ **Right side:**
24
+
25
+ | Segment | Value |
26
+ |---|---|
27
+ | **tokens** | Cumulative input + output tokens for the session (`1.2k`, `45M`) |
28
+ | **cost** | Cumulative session cost (`$0.05`, `$1.23`) |
29
+ | **context** | Context window usage as a percentage; **yellow ≥ 70%, red ≥ 90%** |
30
+ | **extension statuses** | Anything any extension has published via `ctx.ui.setStatus(key, text)` (e.g. caveman's `🪨 caveman:full`) |
31
+
32
+ Segments hide automatically when the data isn't available (no model, no git, zero cost, etc.).
33
+
34
+ ## Commands
35
+
36
+ | Command | Effect |
37
+ |---|---|
38
+ | `/footer` or `/footer status` | Print current state |
39
+ | `/footer on` | Enable (re-activate after `/footer off`) |
40
+ | `/footer off` | Restore pi's built-in footer |
41
+
42
+ Argument autocomplete is wired — type `/footer ` and tab-cycle.
43
+
44
+ ## Icons
45
+
46
+ Auto-detects Nerd Fonts in iTerm, WezTerm, Kitty, Ghostty, Alacritty, and falls back to plain ASCII glyphs everywhere else.
47
+
48
+ Override the auto-detection:
49
+
50
+ ```bash
51
+ export ASTRO_FOOTER_NERD_FONTS=1 # force Nerd Font icons
52
+ export ASTRO_FOOTER_NERD_FONTS=0 # force ASCII icons
53
+ ```
54
+
55
+ Read at extension load — restart pi after changing.
56
+
57
+ ## Extension-status pass-through
58
+
59
+ Any other extension that calls `ctx.ui.setStatus(key, text)` automatically appears on the right side, in muted colour. No config required.
60
+
61
+ This is what surfaces caveman's mode badge, notify-on-stop's state, and any future extension's status without each rolling its own footer renderer.
62
+
63
+ To remove an extension's status from the footer, the extension itself calls `ctx.ui.setStatus(key, undefined)`.
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatCost, formatPathBasename, formatPercent, formatTokens } from "./format.ts";
3
+
4
+ describe("formatTokens", () => {
5
+ it("returns plain integer below 1000", () => {
6
+ expect(formatTokens(0)).toBe("0");
7
+ expect(formatTokens(999)).toBe("999");
8
+ });
9
+
10
+ it("formats thousands with one decimal under 10k", () => {
11
+ expect(formatTokens(1000)).toBe("1.0k");
12
+ expect(formatTokens(1234)).toBe("1.2k");
13
+ expect(formatTokens(9999)).toBe("10.0k");
14
+ });
15
+
16
+ it("formats thousands without decimals at and above 10k", () => {
17
+ expect(formatTokens(10_000)).toBe("10k");
18
+ expect(formatTokens(45_678)).toBe("46k");
19
+ expect(formatTokens(999_999)).toBe("1000k");
20
+ });
21
+
22
+ it("formats millions", () => {
23
+ expect(formatTokens(1_000_000)).toBe("1.0M");
24
+ expect(formatTokens(1_500_000)).toBe("1.5M");
25
+ expect(formatTokens(45_678_900)).toBe("46M");
26
+ });
27
+ });
28
+
29
+ describe("formatCost", () => {
30
+ it("returns $0 for zero", () => {
31
+ expect(formatCost(0)).toBe("$0");
32
+ });
33
+
34
+ it("uses 4 decimals below 1 cent", () => {
35
+ expect(formatCost(0.0001)).toBe("$0.0001");
36
+ expect(formatCost(0.0099)).toBe("$0.0099");
37
+ });
38
+
39
+ it("uses 3 decimals between 1 cent and 1 dollar", () => {
40
+ expect(formatCost(0.01)).toBe("$0.010");
41
+ expect(formatCost(0.123)).toBe("$0.123");
42
+ expect(formatCost(0.999)).toBe("$0.999");
43
+ });
44
+
45
+ it("uses 2 decimals at and above 1 dollar", () => {
46
+ expect(formatCost(1)).toBe("$1.00");
47
+ expect(formatCost(12.345)).toBe("$12.35");
48
+ });
49
+ });
50
+
51
+ describe("formatPercent", () => {
52
+ it("renders em-dash for null", () => {
53
+ expect(formatPercent(null)).toBe("—");
54
+ });
55
+
56
+ it("rounds to nearest integer percent", () => {
57
+ expect(formatPercent(0)).toBe("0%");
58
+ expect(formatPercent(0.4567)).toBe("46%");
59
+ expect(formatPercent(1)).toBe("100%");
60
+ });
61
+ });
62
+
63
+ describe("formatPathBasename", () => {
64
+ it("returns last segment for unix path", () => {
65
+ expect(formatPathBasename("/Users/astro/wb/github/astrofoundry/pi-astro")).toBe("pi-astro");
66
+ });
67
+
68
+ it("strips trailing slashes", () => {
69
+ expect(formatPathBasename("/foo/bar/")).toBe("bar");
70
+ });
71
+
72
+ it("handles root path", () => {
73
+ expect(formatPathBasename("/")).toBe("/");
74
+ });
75
+
76
+ it("handles relative path", () => {
77
+ expect(formatPathBasename("just-folder")).toBe("just-folder");
78
+ });
79
+
80
+ it("handles windows-style separators", () => {
81
+ expect(formatPathBasename("C:\\Users\\astro\\proj")).toBe("proj");
82
+ });
83
+ });
@@ -0,0 +1,23 @@
1
+ export function formatTokens(n: number): string {
2
+ if (n < 1000) return `${n}`;
3
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
4
+ return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)}M`;
5
+ }
6
+
7
+ export function formatCost(usd: number): string {
8
+ if (usd === 0) return "$0";
9
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
10
+ if (usd < 1) return `$${usd.toFixed(3)}`;
11
+ return `$${usd.toFixed(2)}`;
12
+ }
13
+
14
+ export function formatPercent(percent: number | null): string {
15
+ if (percent === null) return "—";
16
+ return `${Math.round(percent * 100)}%`;
17
+ }
18
+
19
+ export function formatPathBasename(cwd: string): string {
20
+ const trimmed = cwd.replace(/[\\/]+$/, "");
21
+ const segments = trimmed.split(/[\\/]/);
22
+ return segments[segments.length - 1] || trimmed || "/";
23
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { detectNerdFonts, iconsFor } from "./icons.ts";
3
+
4
+ describe("detectNerdFonts", () => {
5
+ it("returns true when ASTRO_FOOTER_NERD_FONTS=1", () => {
6
+ expect(detectNerdFonts({ ASTRO_FOOTER_NERD_FONTS: "1" })).toBe(true);
7
+ });
8
+
9
+ it("returns false when ASTRO_FOOTER_NERD_FONTS=0 even on a nerd-friendly terminal", () => {
10
+ expect(detectNerdFonts({ ASTRO_FOOTER_NERD_FONTS: "0", TERM_PROGRAM: "iTerm.app" })).toBe(false);
11
+ });
12
+
13
+ it("detects iTerm via TERM_PROGRAM", () => {
14
+ expect(detectNerdFonts({ TERM_PROGRAM: "iTerm.app" })).toBe(true);
15
+ });
16
+
17
+ it("detects WezTerm via TERM_PROGRAM", () => {
18
+ expect(detectNerdFonts({ TERM_PROGRAM: "WezTerm" })).toBe(true);
19
+ });
20
+
21
+ it("detects ghostty via TERM_PROGRAM", () => {
22
+ expect(detectNerdFonts({ TERM_PROGRAM: "ghostty" })).toBe(true);
23
+ });
24
+
25
+ it("detects kitty via TERM", () => {
26
+ expect(detectNerdFonts({ TERM: "xterm-kitty" })).toBe(true);
27
+ });
28
+
29
+ it("detects iTerm2 via LC_TERMINAL", () => {
30
+ expect(detectNerdFonts({ LC_TERMINAL: "iTerm2" })).toBe(true);
31
+ });
32
+
33
+ it("returns false for an unknown terminal", () => {
34
+ expect(detectNerdFonts({ TERM: "xterm-256color", TERM_PROGRAM: "Apple_Terminal" })).toBe(false);
35
+ });
36
+
37
+ it("returns false when env is empty", () => {
38
+ expect(detectNerdFonts({})).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("iconsFor", () => {
43
+ it("returns nerd-font icons when true", () => {
44
+ const icons = iconsFor(true);
45
+ expect(icons.separator).toBe("");
46
+ expect(icons.git).toBe("");
47
+ });
48
+
49
+ it("returns ascii icons when false", () => {
50
+ const icons = iconsFor(false);
51
+ expect(icons.separator).toBe("›");
52
+ expect(icons.git).toBe("git");
53
+ });
54
+ });
@@ -0,0 +1,54 @@
1
+ export interface IconSet {
2
+ pi: string;
3
+ model: string;
4
+ thinking: string;
5
+ path: string;
6
+ git: string;
7
+ tokens: string;
8
+ cost: string;
9
+ context: string;
10
+ separator: string;
11
+ }
12
+
13
+ const NERD: IconSet = {
14
+ pi: "π",
15
+ model: "",
16
+ thinking: "",
17
+ path: "",
18
+ git: "",
19
+ tokens: "",
20
+ cost: "",
21
+ context: "",
22
+ separator: "",
23
+ };
24
+
25
+ const ASCII: IconSet = {
26
+ pi: "π",
27
+ model: "@",
28
+ thinking: "?",
29
+ path: "/",
30
+ git: "git",
31
+ tokens: "tok",
32
+ cost: "$",
33
+ context: "ctx",
34
+ separator: "›",
35
+ };
36
+
37
+ const NERD_TERM_PROGRAMS = new Set(["iTerm.app", "WezTerm", "ghostty"]);
38
+ const NERD_TERMS = ["xterm-kitty", "alacritty", "wezterm"];
39
+
40
+ export function detectNerdFonts(env: NodeJS.ProcessEnv = process.env): boolean {
41
+ const override = env.ASTRO_FOOTER_NERD_FONTS;
42
+ if (override === "1") return true;
43
+ if (override === "0") return false;
44
+ const program = env.TERM_PROGRAM ?? "";
45
+ if (NERD_TERM_PROGRAMS.has(program)) return true;
46
+ const term = env.TERM ?? "";
47
+ if (NERD_TERMS.some((t) => term === t || term.startsWith(`${t}-`))) return true;
48
+ if (env.LC_TERMINAL === "iTerm2") return true;
49
+ return false;
50
+ }
51
+
52
+ export function iconsFor(useNerd: boolean): IconSet {
53
+ return useNerd ? NERD : ASCII;
54
+ }
@@ -0,0 +1,222 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ type Handler = (event: unknown, ctx: unknown) => void | Promise<void>;
4
+ type CommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
5
+
6
+ interface FakePi {
7
+ handlers: Record<string, Handler>;
8
+ commands: Map<string, { handler: CommandHandler; getArgumentCompletions?: (p: string) => unknown }>;
9
+ getThinkingLevel: ReturnType<typeof vi.fn>;
10
+ on: (event: string, h: Handler) => void;
11
+ registerCommand: (
12
+ name: string,
13
+ opts: {
14
+ description?: string;
15
+ handler: CommandHandler;
16
+ getArgumentCompletions?: (p: string) => unknown;
17
+ },
18
+ ) => void;
19
+ }
20
+
21
+ function makePi(): FakePi {
22
+ const handlers: Record<string, Handler> = {};
23
+ const commands = new Map<string, { handler: CommandHandler; getArgumentCompletions?: (p: string) => unknown }>();
24
+ return {
25
+ handlers,
26
+ commands,
27
+ getThinkingLevel: vi.fn(() => "medium"),
28
+ on: (event, h) => {
29
+ handlers[event] = h;
30
+ },
31
+ registerCommand: (name, opts) => {
32
+ commands.set(name, {
33
+ handler: opts.handler,
34
+ getArgumentCompletions: opts.getArgumentCompletions,
35
+ });
36
+ },
37
+ };
38
+ }
39
+
40
+ interface FakeCtx {
41
+ ui: {
42
+ notify: ReturnType<typeof vi.fn>;
43
+ setFooter: ReturnType<typeof vi.fn>;
44
+ };
45
+ cwd: string;
46
+ model: { id: string } | undefined;
47
+ sessionManager: { getBranch: () => unknown[] };
48
+ getContextUsage: () => { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
49
+ }
50
+
51
+ function makeCtx(overrides: Partial<FakeCtx> = {}): FakeCtx {
52
+ return {
53
+ ui: {
54
+ notify: vi.fn(),
55
+ setFooter: vi.fn(),
56
+ },
57
+ cwd: "/Users/astro/proj",
58
+ model: { id: "anthropic/claude-sonnet-4-6" },
59
+ sessionManager: { getBranch: () => [] },
60
+ getContextUsage: () => undefined,
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe("astro-footer extension", () => {
66
+ const origEnv = { ...process.env };
67
+
68
+ beforeEach(() => {
69
+ vi.resetModules();
70
+ delete process.env.ASTRO_FOOTER_NERD_FONTS;
71
+ delete process.env.TERM_PROGRAM;
72
+ delete process.env.TERM;
73
+ delete process.env.LC_TERMINAL;
74
+ });
75
+
76
+ afterEach(() => {
77
+ for (const k of Object.keys(process.env)) {
78
+ if (!(k in origEnv)) delete process.env[k];
79
+ }
80
+ for (const [k, v] of Object.entries(origEnv)) {
81
+ process.env[k] = v;
82
+ }
83
+ });
84
+
85
+ async function load(): Promise<typeof import("./index.ts")> {
86
+ return await import("./index.ts");
87
+ }
88
+
89
+ async function install(): Promise<{ pi: FakePi; mod: Awaited<ReturnType<typeof load>> }> {
90
+ const mod = await load();
91
+ const pi = makePi();
92
+ mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
93
+ return { pi, mod };
94
+ }
95
+
96
+ it("registers /footer command and session_start handler", async () => {
97
+ const { pi } = await install();
98
+ expect(pi.commands.has("footer")).toBe(true);
99
+ expect(pi.handlers.session_start).toBeDefined();
100
+ });
101
+
102
+ it("session_start activates the footer via ctx.ui.setFooter", async () => {
103
+ const { pi } = await install();
104
+ const ctx = makeCtx();
105
+ await pi.handlers.session_start({}, ctx);
106
+ expect(ctx.ui.setFooter).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("/footer status reports current state", async () => {
110
+ const { pi } = await install();
111
+ const ctx = makeCtx();
112
+ await pi.commands.get("footer")!.handler("status", ctx);
113
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/astro-footer: on/), "info");
114
+ });
115
+
116
+ it("/footer off restores the default footer", async () => {
117
+ const { pi } = await install();
118
+ const ctx = makeCtx();
119
+ await pi.commands.get("footer")!.handler("off", ctx);
120
+ expect(ctx.ui.setFooter).toHaveBeenCalledWith(undefined);
121
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/off/), "info");
122
+ });
123
+
124
+ it("/footer on re-activates after being off", async () => {
125
+ const { pi } = await install();
126
+ const ctx = makeCtx();
127
+ await pi.commands.get("footer")!.handler("off", ctx);
128
+ await pi.commands.get("footer")!.handler("on", ctx);
129
+ const setFooterCalls = ctx.ui.setFooter.mock.calls;
130
+ expect(setFooterCalls[0][0]).toBeUndefined();
131
+ expect(typeof setFooterCalls[1][0]).toBe("function");
132
+ });
133
+
134
+ it("/footer with unknown subcommand warns", async () => {
135
+ const { pi } = await install();
136
+ const ctx = makeCtx();
137
+ await pi.commands.get("footer")!.handler("nonsense", ctx);
138
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/unknown subcommand/), "warning");
139
+ });
140
+
141
+ it("argument autocomplete returns on/off/status", async () => {
142
+ const { pi } = await install();
143
+ const completions = pi.commands.get("footer")!.getArgumentCompletions!("");
144
+ expect(completions).toEqual(
145
+ expect.arrayContaining([
146
+ { value: "on", label: "on" },
147
+ { value: "off", label: "off" },
148
+ { value: "status", label: "status" },
149
+ ]),
150
+ );
151
+ });
152
+
153
+ it("renders a single-line footer with model, path, git, and extension statuses", async () => {
154
+ process.env.ASTRO_FOOTER_NERD_FONTS = "0";
155
+ const { pi } = await install();
156
+ const ctx = makeCtx({
157
+ sessionManager: {
158
+ getBranch: () => [
159
+ {
160
+ type: "message",
161
+ message: {
162
+ role: "assistant",
163
+ usage: { input: 1000, output: 200, cost: { total: 0.05 } },
164
+ },
165
+ },
166
+ ],
167
+ },
168
+ getContextUsage: () => ({ tokens: 50, contextWindow: 200_000, percent: 0.25 }),
169
+ });
170
+ 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
+ };
180
+ const fakeFooterData = {
181
+ getGitBranch: () => "main",
182
+ getExtensionStatuses: () => new Map([["caveman", "🪨 caveman:full"]]),
183
+ getAvailableProviderCount: () => 1,
184
+ onBranchChange: () => () => {},
185
+ };
186
+ const component = factory({}, fakeTheme, fakeFooterData);
187
+ const lines = component.render(120);
188
+ expect(lines).toHaveLength(1);
189
+ const line = lines[0];
190
+ expect(line).toContain("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%");
196
+ expect(line).toContain("🪨 caveman:full");
197
+ });
198
+
199
+ it("renders without right side when there's nothing to show", async () => {
200
+ process.env.ASTRO_FOOTER_NERD_FONTS = "0";
201
+ const { pi } = await install();
202
+ const ctx = makeCtx();
203
+ 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
+ const fakeFooterData = {
211
+ getGitBranch: () => null,
212
+ getExtensionStatuses: () => new Map(),
213
+ getAvailableProviderCount: () => 1,
214
+ onBranchChange: () => () => {},
215
+ };
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");
221
+ });
222
+ });
@@ -0,0 +1,146 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ReadonlyFooterDataProvider,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
7
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
8
+ import { detectNerdFonts, iconsFor, type IconSet } from "./icons.ts";
9
+ import {
10
+ renderContext,
11
+ renderCost,
12
+ renderExtensionStatus,
13
+ renderGit,
14
+ renderModel,
15
+ renderPath,
16
+ renderPi,
17
+ renderThinking,
18
+ renderTokens,
19
+ type ThemeFn,
20
+ } from "./segments.ts";
21
+
22
+ interface UsageTotals {
23
+ input: number;
24
+ output: number;
25
+ cost: number;
26
+ }
27
+
28
+ function collectUsage(ctx: ExtensionContext): UsageTotals {
29
+ const totals: UsageTotals = { input: 0, output: 0, cost: 0 };
30
+ for (const entry of ctx.sessionManager.getBranch()) {
31
+ if (entry.type !== "message") continue;
32
+ if (entry.message.role !== "assistant") continue;
33
+ const msg = entry.message as AssistantMessage;
34
+ totals.input += msg.usage.input;
35
+ totals.output += msg.usage.output;
36
+ totals.cost += msg.usage.cost.total;
37
+ }
38
+ return totals;
39
+ }
40
+
41
+ function buildLine(
42
+ theme: ThemeFn,
43
+ icons: IconSet,
44
+ ctx: ExtensionContext,
45
+ pi: ExtensionAPI,
46
+ footerData: ReadonlyFooterDataProvider,
47
+ width: number,
48
+ ): string {
49
+ const usage = collectUsage(ctx);
50
+ const totalTokens = usage.input + usage.output;
51
+ const contextUsage = ctx.getContextUsage();
52
+ const thinkingLevel = pi.getThinkingLevel();
53
+ const branch = footerData.getGitBranch();
54
+ const statuses = footerData.getExtensionStatuses();
55
+
56
+ const left = [
57
+ renderPi(theme, icons),
58
+ renderModel(theme, icons, ctx.model?.id),
59
+ renderThinking(theme, icons, thinkingLevel),
60
+ renderPath(theme, icons, ctx.cwd),
61
+ renderGit(theme, icons, branch),
62
+ ].filter((s): s is string => Boolean(s));
63
+
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?.percent ?? null);
70
+ if (contextSegment) right.push(contextSegment);
71
+ for (const [, value] of statuses) {
72
+ if (value) right.push(renderExtensionStatus(theme, value));
73
+ }
74
+
75
+ const sep = ` ${theme.fg("border", icons.separator)} `;
76
+ const leftStr = left.join(sep);
77
+ const rightStr = right.join(sep);
78
+
79
+ if (rightStr.length === 0) return truncateToWidth(leftStr, width);
80
+
81
+ const padWidth = Math.max(1, width - visibleWidth(leftStr) - visibleWidth(rightStr));
82
+ return truncateToWidth(leftStr + " ".repeat(padWidth) + rightStr, width);
83
+ }
84
+
85
+ export default function astroFooterExtension(pi: ExtensionAPI): void {
86
+ const useNerd = detectNerdFonts();
87
+ const icons = iconsFor(useNerd);
88
+
89
+ let enabled = true;
90
+
91
+ function activate(ctx: ExtensionContext): void {
92
+ ctx.ui.setFooter((_tui, theme, footerData) => {
93
+ const themeFn: ThemeFn = {
94
+ fg: (role, text) => theme.fg(role, text),
95
+ };
96
+ return {
97
+ invalidate() {},
98
+ render(width: number): string[] {
99
+ return [buildLine(themeFn, icons, ctx, pi, footerData, width)];
100
+ },
101
+ };
102
+ });
103
+ }
104
+
105
+ pi.on("session_start", async (_event, ctx) => {
106
+ if (enabled) activate(ctx);
107
+ });
108
+
109
+ pi.registerCommand("footer", {
110
+ description: "Toggle astro-footer. Usage: /footer [on|off|status].",
111
+ getArgumentCompletions: (prefix) => {
112
+ const items = ["on", "off", "status"];
113
+ const lower = prefix.toLowerCase();
114
+ const matches = items
115
+ .filter((value) => value.startsWith(lower))
116
+ .map((value) => ({ value, label: value }));
117
+ return matches.length > 0 ? matches : null;
118
+ },
119
+ handler: async (args, ctx) => {
120
+ const sub = args.trim().toLowerCase() || "status";
121
+ if (sub === "status") {
122
+ ctx.ui.notify(
123
+ enabled ? `astro-footer: on (${useNerd ? "nerd" : "ascii"} icons)` : "astro-footer: off",
124
+ "info",
125
+ );
126
+ return;
127
+ }
128
+ if (sub === "on") {
129
+ enabled = true;
130
+ activate(ctx);
131
+ ctx.ui.notify("astro-footer: on", "info");
132
+ return;
133
+ }
134
+ if (sub === "off") {
135
+ enabled = false;
136
+ ctx.ui.setFooter(undefined);
137
+ ctx.ui.notify("astro-footer: off (default footer restored)", "info");
138
+ return;
139
+ }
140
+ ctx.ui.notify(
141
+ `astro-footer: unknown subcommand "${sub}". Use on|off|status.`,
142
+ "warning",
143
+ );
144
+ },
145
+ });
146
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { iconsFor } from "./icons.ts";
3
+ import {
4
+ renderContext,
5
+ renderCost,
6
+ renderExtensionStatus,
7
+ renderGit,
8
+ renderModel,
9
+ renderPath,
10
+ renderPi,
11
+ renderThinking,
12
+ renderTokens,
13
+ type ThemeFn,
14
+ } from "./segments.ts";
15
+
16
+ const fakeTheme: ThemeFn = {
17
+ fg: (role, text) => `<${role}>${text}</${role}>`,
18
+ };
19
+
20
+ const ICONS = iconsFor(false);
21
+
22
+ describe("segments", () => {
23
+ it("renderPi shows pi icon with accent color", () => {
24
+ expect(renderPi(fakeTheme, ICONS)).toBe("<accent>π</accent>");
25
+ });
26
+
27
+ it("renderModel returns null when no model", () => {
28
+ expect(renderModel(fakeTheme, ICONS, undefined)).toBeNull();
29
+ });
30
+
31
+ it("renderModel uses the last segment of a slash-separated id", () => {
32
+ const out = renderModel(fakeTheme, ICONS, "anthropic/claude-sonnet-4-6");
33
+ expect(out).toContain("claude-sonnet-4-6");
34
+ expect(out).not.toContain("anthropic/");
35
+ });
36
+
37
+ it("renderThinking returns null when level missing", () => {
38
+ expect(renderThinking(fakeTheme, ICONS, undefined)).toBeNull();
39
+ });
40
+
41
+ it("renderThinking labels each level", () => {
42
+ expect(renderThinking(fakeTheme, ICONS, "off")).toContain("think:off");
43
+ expect(renderThinking(fakeTheme, ICONS, "minimal")).toContain("think:min");
44
+ expect(renderThinking(fakeTheme, ICONS, "low")).toContain("think:low");
45
+ expect(renderThinking(fakeTheme, ICONS, "medium")).toContain("think:med");
46
+ expect(renderThinking(fakeTheme, ICONS, "high")).toContain("think:high");
47
+ expect(renderThinking(fakeTheme, ICONS, "xhigh")).toContain("think:xhi");
48
+ });
49
+
50
+ it("renderThinking falls back gracefully on unknown level", () => {
51
+ const out = renderThinking(fakeTheme, ICONS, "weird");
52
+ expect(out).toContain("think:weird");
53
+ });
54
+
55
+ it("renderPath shows the basename of cwd", () => {
56
+ const out = renderPath(fakeTheme, ICONS, "/Users/astro/proj");
57
+ expect(out).toContain("proj");
58
+ });
59
+
60
+ it("renderGit returns null when no branch", () => {
61
+ expect(renderGit(fakeTheme, ICONS, null)).toBeNull();
62
+ });
63
+
64
+ it("renderGit shows branch name in success colour", () => {
65
+ const out = renderGit(fakeTheme, ICONS, "feat-foo");
66
+ expect(out).toContain("<success>feat-foo</success>");
67
+ });
68
+
69
+ it("renderTokens hides when total is zero", () => {
70
+ expect(renderTokens(fakeTheme, ICONS, 0)).toBeNull();
71
+ });
72
+
73
+ it("renderTokens formats large totals", () => {
74
+ expect(renderTokens(fakeTheme, ICONS, 1234)).toContain("1.2k");
75
+ });
76
+
77
+ it("renderCost hides when zero", () => {
78
+ expect(renderCost(fakeTheme, ICONS, 0)).toBeNull();
79
+ });
80
+
81
+ it("renderCost formats sub-dollar costs", () => {
82
+ expect(renderCost(fakeTheme, ICONS, 0.123)).toContain("$0.123");
83
+ });
84
+
85
+ it("renderContext hides when percent is null", () => {
86
+ expect(renderContext(fakeTheme, ICONS, null)).toBeNull();
87
+ });
88
+
89
+ it("renderContext uses dim colour below 70%", () => {
90
+ const out = renderContext(fakeTheme, ICONS, 0.5);
91
+ expect(out).toContain("<dim>");
92
+ });
93
+
94
+ it("renderContext uses warning colour at and above 70%", () => {
95
+ const out = renderContext(fakeTheme, ICONS, 0.7);
96
+ expect(out).toContain("<warning>");
97
+ });
98
+
99
+ it("renderContext uses error colour at and above 90%", () => {
100
+ const out = renderContext(fakeTheme, ICONS, 0.95);
101
+ expect(out).toContain("<error>");
102
+ });
103
+
104
+ it("renderExtensionStatus uses muted colour", () => {
105
+ expect(renderExtensionStatus(fakeTheme, "🪨 caveman:full")).toBe("<muted>🪨 caveman:full</muted>");
106
+ });
107
+ });
@@ -0,0 +1,76 @@
1
+ import type { ThemeColor } from "@mariozechner/pi-coding-agent";
2
+ import type { IconSet } from "./icons.ts";
3
+ import { formatCost, formatPathBasename, formatPercent, formatTokens } from "./format.ts";
4
+
5
+ export interface ThemeFn {
6
+ fg: (role: ThemeColor, text: string) => string;
7
+ }
8
+
9
+ const THINKING_LABELS: Record<string, string> = {
10
+ off: "off",
11
+ minimal: "min",
12
+ low: "low",
13
+ medium: "med",
14
+ high: "high",
15
+ xhigh: "xhi",
16
+ };
17
+
18
+ const THINKING_COLORS: Record<string, ThemeColor> = {
19
+ off: "dim",
20
+ minimal: "muted",
21
+ low: "accent",
22
+ medium: "accent",
23
+ high: "warning",
24
+ xhigh: "error",
25
+ };
26
+
27
+ export function renderPi(theme: ThemeFn, icons: IconSet): string {
28
+ return theme.fg("accent", icons.pi);
29
+ }
30
+
31
+ export function renderModel(theme: ThemeFn, icons: IconSet, modelId: string | undefined): string | null {
32
+ if (!modelId) return null;
33
+ const short = modelId.split("/").pop() ?? modelId;
34
+ return `${theme.fg("muted", icons.model)} ${theme.fg("text", short)}`.trim();
35
+ }
36
+
37
+ export function renderThinking(theme: ThemeFn, icons: IconSet, level: string | undefined): string | null {
38
+ if (!level) return null;
39
+ const label = THINKING_LABELS[level] ?? level;
40
+ const color = THINKING_COLORS[level] ?? "dim";
41
+ return `${theme.fg(color, icons.thinking)} ${theme.fg(color, `think:${label}`)}`.trim();
42
+ }
43
+
44
+ export function renderPath(theme: ThemeFn, icons: IconSet, cwd: string): string {
45
+ const name = formatPathBasename(cwd);
46
+ return `${theme.fg("dim", icons.path)} ${theme.fg("accent", name)}`.trim();
47
+ }
48
+
49
+ export function renderGit(theme: ThemeFn, icons: IconSet, branch: string | null): string | null {
50
+ if (!branch) return null;
51
+ return `${theme.fg("success", icons.git)} ${theme.fg("success", branch)}`.trim();
52
+ }
53
+
54
+ export function renderTokens(theme: ThemeFn, icons: IconSet, totalTokens: number): string | null {
55
+ if (totalTokens === 0) return null;
56
+ return `${theme.fg("muted", icons.tokens)} ${theme.fg("text", formatTokens(totalTokens))}`.trim();
57
+ }
58
+
59
+ export function renderCost(theme: ThemeFn, icons: IconSet, costUsd: number): string | null {
60
+ if (costUsd === 0) return null;
61
+ return `${theme.fg("muted", icons.cost)} ${theme.fg("text", formatCost(costUsd))}`.trim();
62
+ }
63
+
64
+ export function renderContext(
65
+ theme: ThemeFn,
66
+ icons: IconSet,
67
+ percent: number | null,
68
+ ): string | null {
69
+ if (percent === null) return null;
70
+ const role: ThemeColor = percent >= 0.9 ? "error" : percent >= 0.7 ? "warning" : "dim";
71
+ return `${theme.fg(role, icons.context)} ${theme.fg(role, formatPercent(percent))}`.trim();
72
+ }
73
+
74
+ export function renderExtensionStatus(theme: ThemeFn, value: string): string {
75
+ return theme.fg("muted", value);
76
+ }
@@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
4
4
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
5
5
  import { GoogleGenAI } from "@google/genai";
6
6
  import { StringEnum } from "@mariozechner/pi-ai";
7
+ import { Text } from "@mariozechner/pi-tui";
7
8
  import { Type } from "typebox";
8
9
  import { persistApiKey, resolveExistingApiKey } from "./credentials.ts";
9
10
  import {
@@ -72,6 +73,13 @@ type Params = {
72
73
  skip_confirm?: boolean;
73
74
  };
74
75
 
76
+ interface GeminiImageDetails {
77
+ model: ImageModel;
78
+ images: number;
79
+ savedPaths: string[];
80
+ actualCostUsd?: number;
81
+ }
82
+
75
83
  async function ensureApiKey(ctx: ExtensionContext): Promise<string> {
76
84
  const existing = resolveExistingApiKey();
77
85
  if (existing) return existing;
@@ -410,15 +418,35 @@ export default function geminiImageExtension(pi: ExtensionAPI): void {
410
418
  ...call.images.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })),
411
419
  ];
412
420
 
413
- return {
414
- content,
415
- details: {
416
- model: finalModel,
417
- images: call.images.length,
418
- savedPaths,
419
- actualCostUsd: actual?.usd,
420
- },
421
+ const details: GeminiImageDetails = {
422
+ model: finalModel,
423
+ images: call.images.length,
424
+ savedPaths,
425
+ actualCostUsd: actual?.usd,
421
426
  };
427
+
428
+ return { content, details };
429
+ },
430
+ renderResult(result, { expanded, isPartial }, theme, _context) {
431
+ if (isPartial) {
432
+ return new Text(theme.fg("warning", "Generating..."), 0, 0);
433
+ }
434
+ const details = result.details as GeminiImageDetails | undefined;
435
+ if (!details) {
436
+ return new Text(theme.fg("dim", "(no details)"), 0, 0);
437
+ }
438
+ const plural = details.images === 1 ? "image" : "images";
439
+ let text = theme.fg("toolTitle", theme.bold(details.model));
440
+ text += theme.fg("muted", ` · ${details.images} ${plural}`);
441
+ if (typeof details.actualCostUsd === "number") {
442
+ text += theme.fg("dim", ` · $${details.actualCostUsd.toFixed(4)}`);
443
+ }
444
+ if (expanded) {
445
+ for (const path of details.savedPaths) {
446
+ text += "\n" + theme.fg("dim", ` ${path}`);
447
+ }
448
+ }
449
+ return new Text(text, 0, 0);
422
450
  },
423
451
  });
424
452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"