@astrofoundry/pi-astro 0.9.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 +2 -1
- package/extensions/astro-agents/index.ts +16 -6
- package/extensions/astro-agents/spawn.test.ts +97 -0
- package/extensions/astro-agents/spawn.ts +29 -2
- package/extensions/astro-footer/README.md +63 -0
- package/extensions/astro-footer/format.test.ts +83 -0
- package/extensions/astro-footer/format.ts +23 -0
- package/extensions/astro-footer/icons.test.ts +54 -0
- package/extensions/astro-footer/icons.ts +54 -0
- package/extensions/astro-footer/index.test.ts +222 -0
- package/extensions/astro-footer/index.ts +146 -0
- package/extensions/astro-footer/segments.test.ts +107 -0
- package/extensions/astro-footer/segments.ts +76 -0
- package/extensions/gemini-image/index.ts +36 -8
- package/extensions/notify-on-stop/README.md +114 -0
- package/extensions/notify-on-stop/index.test.ts +149 -76
- package/extensions/notify-on-stop/index.ts +62 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,10 +62,11 @@ pi # launch; confirm [Extensions] lists astro-agents, claude-glob
|
|
|
62
62
|
- `multi-edit` — registers the enhanced `edit` tool
|
|
63
63
|
- `gemini-image` — registers `gemini_image` tool (requires a Gemini API key; prompts and saves on first use)
|
|
64
64
|
- `security-guard` — blocks/prompts destructive bash commands and sensitive file access; configure at `~/.pi/agent/security-guard.json` (example written on first run); `/security-guard status|reload|test`
|
|
65
|
-
- `notify-on-stop` — runs a shell command when the agent finishes a turn (sound, voice, desktop notification). macOS default: plays the Glass system sound **and** speaks "Agent done" via `say` using the **`Samantha (Enhanced)`** voice. Linux default: `notify-send "pi" "Agent done"`. Override the voice with `PI_STOP_NOTIFY_VOICE=<voice-name>` (macOS only; e.g. `Alex`, `Karen`, `Daniel (Enhanced)`). Replace the full command with `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`.
|
|
65
|
+
- `notify-on-stop` — runs a shell command when the agent finishes a turn (sound, voice, desktop notification). **Default: off.** Enable with `/notify on` (state persists in `~/.pi/agent/notify-on-stop.json`); disable with `/notify off`. macOS default command: plays the Glass system sound **and** speaks "Agent done" via `say` using the **`Samantha (Enhanced)`** voice. Linux default: `notify-send "pi" "Agent done"`. Override the voice with `PI_STOP_NOTIFY_VOICE=<voice-name>` (macOS only; e.g. `Alex`, `Karen`, `Daniel (Enhanced)`). Replace the full command with `PI_STOP_NOTIFY='afplay /System/Library/Sounds/Glass.aiff && say "Done"'`. Hard-kill (overrides `/notify on`) with `PI_STOP_NOTIFY_OFF=1`. Commands: `/notify [on|off|status|test]`. See [extensions/notify-on-stop/README.md](extensions/notify-on-stop/README.md) for full details.
|
|
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,
|
|
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
|
|
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
|
+
}
|