@astrofoundry/pi-astro 0.11.2 → 0.12.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
@@ -1,6 +1,6 @@
1
1
  # @astrofoundry/pi-astro
2
2
 
3
- Personal customizations for the [pi coding agent](https://github.com/badlogic/pi-mono) — registers an `astro_agent` tool, a `grimoire` docs tool, a `claude-globals` rules-injection extension, a `caveman` compressed-output toggle, 8 curated subagents, 4 skills, and an `astro` theme.
3
+ Personal customizations for the [pi coding agent](https://github.com/badlogic/pi-mono) — registers an `astro_agent` tool, a `grimoire` docs tool, a `caveman` compressed-output toggle, an `astro-footer` status bar, 8 curated subagents, 4 skills, and an `astro` theme.
4
4
 
5
5
  ## Full setup
6
6
 
@@ -45,7 +45,7 @@ Or run `/settings` inside pi and select `astro`.
45
45
 
46
46
  ```bash
47
47
  pi list # package shows installed
48
- pi # launch; confirm [Extensions] lists astro-agents, claude-globals, grimoire
48
+ pi # launch; confirm [Extensions] lists astro-agents, grimoire, astro-footer
49
49
  ```
50
50
 
51
51
  ## What's inside
@@ -65,8 +65,8 @@ pi # launch; confirm [Extensions] lists astro-agents, claude-glob
65
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
- - `claude-globals` — auto-injects `~/.claude/CLAUDE.md` into every pi session's system prompt
69
68
  - `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
+ - `astro-snake` — `/snake` launches an in-pi Snake game (Astro-themed, theme-aware colours, speed ramp, no persistence). See [extensions/astro-snake/README.md](extensions/astro-snake/README.md).
70
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`.
71
71
 
72
72
  **Bundled subagents** (callable via `astro_agent`):
@@ -53,10 +53,11 @@ describe("formatPercent", () => {
53
53
  expect(formatPercent(null)).toBe("—");
54
54
  });
55
55
 
56
- it("rounds to nearest integer percent (input is 0-100, not 0-1)", () => {
57
- expect(formatPercent(0)).toBe("0%");
58
- expect(formatPercent(45.67)).toBe("46%");
59
- expect(formatPercent(100)).toBe("100%");
56
+ it("formats with one decimal place (input is 0-100, not 0-1)", () => {
57
+ expect(formatPercent(0)).toBe("0.0%");
58
+ expect(formatPercent(0.05)).toBe("0.1%");
59
+ expect(formatPercent(45.67)).toBe("45.7%");
60
+ expect(formatPercent(100)).toBe("100.0%");
60
61
  });
61
62
  });
62
63
 
@@ -13,7 +13,7 @@ export function formatCost(usd: number): string {
13
13
 
14
14
  export function formatPercent(percent: number | null): string {
15
15
  if (percent === null) return "—";
16
- return `${Math.round(percent)}%`;
16
+ return `${percent.toFixed(1)}%`;
17
17
  }
18
18
 
19
19
  export function formatPathBasename(cwd: string): string {
@@ -18,7 +18,7 @@ const NERD: IconSet = {
18
18
  git: "",
19
19
  tokens: "",
20
20
  cost: "",
21
- context: "",
21
+ context: "",
22
22
  separator: "|",
23
23
  };
24
24
 
@@ -30,7 +30,7 @@ const ASCII: IconSet = {
30
30
  git: "git",
31
31
  tokens: "tok",
32
32
  cost: "$",
33
- context: "ctx",
33
+ context: "",
34
34
  separator: "|",
35
35
  };
36
36
 
@@ -184,7 +184,7 @@ describe("astro-footer extension", () => {
184
184
  onBranchChange: () => () => {},
185
185
  };
186
186
  const component = factory({}, fakeTheme, fakeFooterData);
187
- const lines = component.render(120);
187
+ const lines = component.render(200);
188
188
  expect(lines).toHaveLength(1);
189
189
  const line = lines[0];
190
190
  expect(line).toContain("Anthropic: Claude Sonnet 4 6");
@@ -192,7 +192,7 @@ describe("astro-footer extension", () => {
192
192
  expect(line).toContain("main");
193
193
  expect(line).toContain("1.2k");
194
194
  expect(line).toContain("$0.05");
195
- expect(line).toContain("25%");
195
+ expect(line).toContain("25.0%/200k");
196
196
  expect(line).toContain("🪨 caveman:full");
197
197
  });
198
198
 
@@ -66,7 +66,9 @@ function buildLine(
66
66
  if (tokenSegment) right.push(tokenSegment);
67
67
  const costSegment = renderCost(theme, icons, usage.cost);
68
68
  if (costSegment) right.push(costSegment);
69
- const contextSegment = renderContext(theme, icons, contextUsage?.percent ?? null);
69
+ const contextSegment = renderContext(theme, icons, contextUsage
70
+ ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow }
71
+ : null);
70
72
  if (contextSegment) right.push(contextSegment);
71
73
  for (const [, value] of statuses) {
72
74
  if (value) right.push(renderExtensionStatus(theme, value));
@@ -82,26 +82,33 @@ describe("segments", () => {
82
82
  expect(renderCost(fakeTheme, ICONS, 0.123)).toContain("$0.123");
83
83
  });
84
84
 
85
- it("renderContext hides when percent is null", () => {
85
+ it("renderContext hides when input is null or percent is null", () => {
86
86
  expect(renderContext(fakeTheme, ICONS, null)).toBeNull();
87
+ expect(renderContext(fakeTheme, ICONS, { percent: null, contextWindow: 256_000 })).toBeNull();
87
88
  });
88
89
 
89
90
  it("renderContext uses dim colour below 70%", () => {
90
- const out = renderContext(fakeTheme, ICONS, 50);
91
+ const out = renderContext(fakeTheme, ICONS, { percent: 50, contextWindow: 256_000 });
91
92
  expect(out).toContain("<dim>");
92
- expect(out).toContain("50%");
93
+ expect(out).toContain("50.0%/256k");
93
94
  });
94
95
 
95
96
  it("renderContext uses warning colour at and above 70%", () => {
96
- const out = renderContext(fakeTheme, ICONS, 70);
97
+ const out = renderContext(fakeTheme, ICONS, { percent: 70, contextWindow: 200_000 });
97
98
  expect(out).toContain("<warning>");
98
- expect(out).toContain("70%");
99
+ expect(out).toContain("70.0%/200k");
99
100
  });
100
101
 
101
102
  it("renderContext uses error colour at and above 90%", () => {
102
- const out = renderContext(fakeTheme, ICONS, 95);
103
+ const out = renderContext(fakeTheme, ICONS, { percent: 95.5, contextWindow: 1_000_000 });
103
104
  expect(out).toContain("<error>");
104
- expect(out).toContain("95%");
105
+ expect(out).toContain("95.5%/1.0M");
106
+ });
107
+
108
+ it("renderContext omits the /total suffix when contextWindow is missing", () => {
109
+ const out = renderContext(fakeTheme, ICONS, { percent: 25, contextWindow: 0 });
110
+ expect(out).toContain("25.0%");
111
+ expect(out).not.toContain("25.0%/");
105
112
  });
106
113
 
107
114
  it("renderExtensionStatus uses muted colour", () => {
@@ -62,14 +62,21 @@ export function renderCost(theme: ThemeFn, icons: IconSet, costUsd: number): str
62
62
  return `${theme.fg("muted", icons.cost)} ${theme.fg("text", formatCost(costUsd))}`.trim();
63
63
  }
64
64
 
65
+ export interface ContextSegmentInput {
66
+ percent: number | null;
67
+ contextWindow: number | null;
68
+ }
69
+
65
70
  export function renderContext(
66
71
  theme: ThemeFn,
67
72
  icons: IconSet,
68
- percent: number | null,
73
+ input: ContextSegmentInput | null,
69
74
  ): string | null {
70
- if (percent === null) return null;
71
- const role: ThemeColor = percent >= 90 ? "error" : percent >= 70 ? "warning" : "dim";
72
- return `${theme.fg(role, icons.context)} ${theme.fg(role, formatPercent(percent))}`.trim();
75
+ if (!input || input.percent === null) return null;
76
+ const role: ThemeColor = input.percent >= 90 ? "error" : input.percent >= 70 ? "warning" : "dim";
77
+ const total = input.contextWindow && input.contextWindow > 0 ? `/${formatTokens(input.contextWindow)}` : "";
78
+ const value = `${formatPercent(input.percent)}${total}`;
79
+ return `${theme.fg(role, icons.context)} ${theme.fg(role, value)}`.trim();
73
80
  }
74
81
 
75
82
  export function renderExtensionStatus(theme: ThemeFn, value: string): string {
@@ -0,0 +1,36 @@
1
+ # astro-snake
2
+
3
+ Play **Astro Snake** inside pi.
4
+
5
+ ```
6
+ /snake
7
+ ```
8
+
9
+ Ships as part of [`@astrofoundry/pi-astro`](../../README.md). No extra install.
10
+
11
+ ## What it is
12
+
13
+ A small, theme-aware Snake game rendered in pi's overlay UI. Always starts a fresh game — no saves, no resume, no high scores, no out-of-session state.
14
+
15
+ ## Controls
16
+
17
+ | Key | Action |
18
+ |---|---|
19
+ | `↑ ↓ ← →` or `W A S D` | Steer |
20
+ | Press any direction key on launch | Starts the game |
21
+ | `Q` or `Esc` | Quit |
22
+ | `R` or `Space` (after game over / win) | Play again |
23
+
24
+ The first directional key launches the snake — until then the game waits, so you don't lose 2 seconds before you've focused the terminal.
25
+
26
+ ## Features
27
+
28
+ - **Speed ramps with score** — tick interval shrinks linearly from 100 ms down to 40 ms by the time you reach 120 points (12 food).
29
+ - **Theme-aware colours** — uses your active pi theme: `success` for the snake, `accent` for the food and title, `error` for the game-over banner, `warning`/`muted`/`dim` for messages and chrome. No hardcoded ANSI.
30
+ - **Astro branding** — `★ ASTRO SNAKE ★` title, `✦` sparkle food, `Stardust` score label.
31
+ - **Win condition** — fill the entire 40×15 board (yes, it's possible) and the game ends with `Board cleared!`.
32
+ - **Wait-to-launch** — game starts paused, first arrow key sends the snake.
33
+
34
+ ## Notes
35
+
36
+ State lives entirely in memory for the duration of the game; nothing is persisted.
@@ -0,0 +1,199 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AstroSnakeComponent, type TuiHandle } from "./component.ts";
3
+
4
+ interface FakeTheme {
5
+ fg: (role: string, text: string) => string;
6
+ bold: (text: string) => string;
7
+ }
8
+
9
+ function makeTheme(): FakeTheme {
10
+ return {
11
+ fg: (role, text) => `<${role}>${text}</${role}>`,
12
+ bold: (text) => `*${text}*`,
13
+ };
14
+ }
15
+
16
+ function makeTui(): TuiHandle & { renders: number } {
17
+ const handle = {
18
+ renders: 0,
19
+ requestRender(): void {
20
+ handle.renders++;
21
+ },
22
+ };
23
+ return handle;
24
+ }
25
+
26
+ interface Harness {
27
+ component: AstroSnakeComponent;
28
+ tui: TuiHandle & { renders: number };
29
+ closed: { count: number };
30
+ }
31
+
32
+ function makeComponent(): Harness {
33
+ const tui = makeTui();
34
+ const theme = makeTheme();
35
+ const closed = { count: 0 };
36
+ const component = new AstroSnakeComponent(
37
+ tui,
38
+ theme as unknown as ConstructorParameters<typeof AstroSnakeComponent>[1],
39
+ () => {
40
+ closed.count++;
41
+ },
42
+ );
43
+ return { component, tui, closed };
44
+ }
45
+
46
+ describe("AstroSnakeComponent", () => {
47
+ beforeEach(() => {
48
+ vi.useFakeTimers();
49
+ });
50
+
51
+ afterEach(() => {
52
+ vi.useRealTimers();
53
+ });
54
+
55
+ it("starts in 'starting' status with the launch prompt visible", () => {
56
+ const { component } = makeComponent();
57
+ expect(component.getState().status).toBe("starting");
58
+ const lines = component.render(120);
59
+ expect(lines.join("\n")).toContain("ASTRO SNAKE");
60
+ expect(lines.join("\n")).toContain("Ready");
61
+ });
62
+
63
+ it("first directional key launches the game (status 'running')", () => {
64
+ const { component } = makeComponent();
65
+ component.handleInput("\x1b[A");
66
+ const state = component.getState();
67
+ expect(state.status).toBe("running");
68
+ expect(state.nextDirection).toBe("up");
69
+ component.dispose();
70
+ });
71
+
72
+ it("Q while running quits and calls onClose", () => {
73
+ const { component, closed } = makeComponent();
74
+ component.handleInput("Q");
75
+ expect(closed.count).toBe(1);
76
+ });
77
+
78
+ it("ESC while running quits and calls onClose", () => {
79
+ const { component, closed } = makeComponent();
80
+ component.handleInput("\x1b");
81
+ expect(closed.count).toBe(1);
82
+ });
83
+
84
+ it("ignores unknown keys while running", () => {
85
+ const { component, closed } = makeComponent();
86
+ component.handleInput("x");
87
+ expect(closed.count).toBe(0);
88
+ expect(component.getState().status).toBe("starting");
89
+ });
90
+
91
+ it("R after gameover restarts the game", () => {
92
+ const { component } = makeComponent();
93
+ component.handleInput("\x1b[C");
94
+ const stateBefore = component.getState();
95
+ const overState = { ...stateBefore, status: "gameover" as const, score: 50 };
96
+ Object.assign(component as unknown as { state: typeof stateBefore }, { state: overState });
97
+ component.handleInput("R");
98
+ const stateAfter = component.getState();
99
+ expect(stateAfter.status).toBe("starting");
100
+ expect(stateAfter.score).toBe(0);
101
+ component.dispose();
102
+ });
103
+
104
+ it("Q after gameover quits", () => {
105
+ const { component, closed } = makeComponent();
106
+ component.handleInput("\x1b[C");
107
+ const stateBefore = component.getState();
108
+ Object.assign(component as unknown as { state: typeof stateBefore }, {
109
+ state: { ...stateBefore, status: "gameover" as const },
110
+ });
111
+ component.handleInput("Q");
112
+ expect(closed.count).toBe(1);
113
+ });
114
+
115
+ it("ignores unknown keys after gameover", () => {
116
+ const { component, closed } = makeComponent();
117
+ const state = component.getState();
118
+ Object.assign(component as unknown as { state: typeof state }, {
119
+ state: { ...state, status: "gameover" as const },
120
+ });
121
+ component.handleInput("x");
122
+ expect(closed.count).toBe(0);
123
+ expect(component.getState().status).toBe("gameover");
124
+ });
125
+
126
+ it("renders the gameover footer when status is gameover", () => {
127
+ const { component } = makeComponent();
128
+ const state = component.getState();
129
+ Object.assign(component as unknown as { state: typeof state }, {
130
+ state: { ...state, status: "gameover" as const },
131
+ });
132
+ component.invalidate();
133
+ expect(component.render(120).join("\n")).toContain("Mission lost");
134
+ });
135
+
136
+ it("renders the won footer when status is won", () => {
137
+ const { component } = makeComponent();
138
+ const state = component.getState();
139
+ Object.assign(component as unknown as { state: typeof state }, {
140
+ state: { ...state, status: "won" as const },
141
+ });
142
+ component.invalidate();
143
+ expect(component.render(120).join("\n")).toContain("Board cleared");
144
+ });
145
+
146
+ it("running footer mentions WASD controls", () => {
147
+ const { component } = makeComponent();
148
+ const state = component.getState();
149
+ Object.assign(component as unknown as { state: typeof state }, {
150
+ state: { ...state, status: "running" as const },
151
+ });
152
+ component.invalidate();
153
+ expect(component.render(120).join("\n")).toContain("WASD");
154
+ });
155
+
156
+ it("invalidate forces a re-render on next call (cache miss)", () => {
157
+ const { component } = makeComponent();
158
+ const a = component.render(120);
159
+ component.invalidate();
160
+ const b = component.render(120);
161
+ expect(a).not.toBe(b);
162
+ });
163
+
164
+ it("subsequent render with same width and version returns cached array", () => {
165
+ const { component } = makeComponent();
166
+ const a = component.render(120);
167
+ const b = component.render(120);
168
+ expect(a).toBe(b);
169
+ });
170
+
171
+ it("steers via WASD in addition to arrow keys", () => {
172
+ const { component } = makeComponent();
173
+ component.handleInput("d");
174
+ expect(component.getState().nextDirection).toBe("right");
175
+ component.handleInput("w");
176
+ expect(component.getState().nextDirection).toBe("up");
177
+ component.dispose();
178
+ });
179
+
180
+ it("ticks the snake forward after launch (fake timers advance state)", () => {
181
+ const { component, tui } = makeComponent();
182
+ component.handleInput("\x1b[C");
183
+ const headBefore = component.getState().snake[0];
184
+ vi.advanceTimersByTime(150);
185
+ const headAfter = component.getState().snake[0];
186
+ expect(headAfter).not.toEqual(headBefore);
187
+ expect(tui.renders).toBeGreaterThan(1);
188
+ component.dispose();
189
+ });
190
+
191
+ it("dispose clears the running timer (no further state changes)", () => {
192
+ const { component } = makeComponent();
193
+ component.handleInput("\x1b[C");
194
+ component.dispose();
195
+ const headBefore = component.getState().snake[0];
196
+ vi.advanceTimersByTime(500);
197
+ expect(component.getState().snake[0]).toEqual(headBefore);
198
+ });
199
+ });
@@ -0,0 +1,184 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
3
+ import {
4
+ applyTick,
5
+ changeDirection,
6
+ createInitialState,
7
+ GAME_HEIGHT,
8
+ GAME_WIDTH,
9
+ type Direction,
10
+ type GameState,
11
+ tickIntervalForScore,
12
+ } from "./game.ts";
13
+
14
+ export interface TuiHandle {
15
+ requestRender(): void;
16
+ }
17
+
18
+ export class AstroSnakeComponent {
19
+ private state: GameState;
20
+ private timer: ReturnType<typeof setTimeout> | null = null;
21
+ private cachedLines: string[] = [];
22
+ private cachedWidth = 0;
23
+ private version = 0;
24
+ private cachedVersion = -1;
25
+
26
+ constructor(
27
+ private readonly tui: TuiHandle,
28
+ private readonly theme: Theme,
29
+ private readonly onClose: () => void,
30
+ ) {
31
+ this.state = createInitialState();
32
+ }
33
+
34
+ getState(): GameState {
35
+ return this.state;
36
+ }
37
+
38
+ handleInput(data: string): void {
39
+ if (this.state.status === "gameover" || this.state.status === "won") {
40
+ if (data === "r" || data === "R" || data === " ") {
41
+ this.state = createInitialState();
42
+ this.bump();
43
+ return;
44
+ }
45
+ if (matchesKey(data, "escape") || data === "q" || data === "Q") {
46
+ this.dispose();
47
+ this.onClose();
48
+ return;
49
+ }
50
+ return;
51
+ }
52
+
53
+ if (matchesKey(data, "escape") || data === "q" || data === "Q") {
54
+ this.dispose();
55
+ this.onClose();
56
+ return;
57
+ }
58
+
59
+ let req: Direction | null = null;
60
+ if (matchesKey(data, "up") || data === "w" || data === "W") req = "up";
61
+ else if (matchesKey(data, "down") || data === "s" || data === "S") req = "down";
62
+ else if (matchesKey(data, "left") || data === "a" || data === "A") req = "left";
63
+ else if (matchesKey(data, "right") || data === "d" || data === "D") req = "right";
64
+
65
+ if (!req) return;
66
+
67
+ const wasStarting = this.state.status === "starting";
68
+ this.state = changeDirection(this.state, req);
69
+ this.bump();
70
+ if (wasStarting && this.state.status === "running") {
71
+ this.scheduleNextTick();
72
+ }
73
+ }
74
+
75
+ private scheduleNextTick(): void {
76
+ if (this.timer !== null) return;
77
+ if (this.state.status !== "running") return;
78
+ const delay = tickIntervalForScore(this.state.score);
79
+ this.timer = setTimeout(() => {
80
+ this.timer = null;
81
+ if (this.state.status !== "running") return;
82
+ this.state = applyTick(this.state);
83
+ this.bump();
84
+ this.scheduleNextTick();
85
+ }, delay);
86
+ }
87
+
88
+ private bump(): void {
89
+ this.version++;
90
+ this.tui.requestRender();
91
+ }
92
+
93
+ invalidate(): void {
94
+ this.cachedWidth = 0;
95
+ }
96
+
97
+ render(width: number): string[] {
98
+ if (width === this.cachedWidth && this.cachedVersion === this.version) {
99
+ return this.cachedLines;
100
+ }
101
+
102
+ const t = this.theme;
103
+ const cellWidth = 2;
104
+ const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
105
+ const effectiveHeight = GAME_HEIGHT;
106
+ const boxWidth = effectiveWidth * cellWidth;
107
+
108
+ const dim = (s: string) => t.fg("dim", s);
109
+ const muted = (s: string) => t.fg("muted", s);
110
+ const accent = (s: string) => t.fg("accent", s);
111
+ const success = (s: string) => t.fg("success", s);
112
+ const error = (s: string) => t.fg("error", s);
113
+ const text = (s: string) => t.fg("text", s);
114
+ const bold = t.bold.bind(t);
115
+
116
+ const lines: string[] = [];
117
+
118
+ const boxLine = (content: string): string => {
119
+ const padding = Math.max(0, boxWidth - visibleWidth(content));
120
+ return dim(" │") + content + " ".repeat(padding) + dim("│");
121
+ };
122
+
123
+ lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
124
+
125
+ const star = bold(accent("★"));
126
+ const title = `${star} ${bold(accent("ASTRO SNAKE"))} ${star}`;
127
+ const stardust = `${muted("Stardust:")} ${bold(accent(String(this.state.score)))}`;
128
+ lines.push(this.padLine(boxLine(`${title} ${dim("│")} ${stardust}`), width));
129
+
130
+ lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
131
+
132
+ const head = this.state.snake[0];
133
+ const bodySet = new Set(this.state.snake.slice(1).map((p) => `${p.x},${p.y}`));
134
+ const food = this.state.food;
135
+ for (let y = 0; y < effectiveHeight; y++) {
136
+ let row = "";
137
+ for (let x = 0; x < effectiveWidth; x++) {
138
+ if (head.x === x && head.y === y) {
139
+ row += success("██");
140
+ } else if (bodySet.has(`${x},${y}`)) {
141
+ row += success("▓▓");
142
+ } else if (food && food.x === x && food.y === y) {
143
+ row += accent("✦ ");
144
+ } else {
145
+ row += " ";
146
+ }
147
+ }
148
+ lines.push(this.padLine(dim(" │") + row + dim("│"), width));
149
+ }
150
+
151
+ lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
152
+
153
+ let footer: string;
154
+ if (this.state.status === "starting") {
155
+ footer = `${accent(bold("Ready"))} ${muted("Press an arrow key (or WASD) to launch")}`;
156
+ } else if (this.state.status === "gameover") {
157
+ footer = `${error(bold("Mission lost!"))} ${muted("Press")} ${bold(text("R"))} ${muted("to retry,")} ${bold(text("Q"))} ${muted("to abort")}`;
158
+ } else if (this.state.status === "won") {
159
+ footer = `${accent(bold("Board cleared!"))} ${muted("Press")} ${bold(text("R"))} ${muted("to fly again,")} ${bold(text("Q"))} ${muted("to land")}`;
160
+ } else {
161
+ footer = `${muted("↑↓←→ or WASD to fly,")} ${bold(text("Q"))} ${muted("or")} ${bold(text("ESC"))} ${muted("to abort")}`;
162
+ }
163
+ lines.push(this.padLine(boxLine(footer), width));
164
+
165
+ lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
166
+
167
+ this.cachedLines = lines;
168
+ this.cachedWidth = width;
169
+ this.cachedVersion = this.version;
170
+ return lines;
171
+ }
172
+
173
+ private padLine(line: string, width: number): string {
174
+ const padding = Math.max(0, width - visibleWidth(line));
175
+ return line + " ".repeat(padding);
176
+ }
177
+
178
+ dispose(): void {
179
+ if (this.timer !== null) {
180
+ clearTimeout(this.timer);
181
+ this.timer = null;
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ applyTick,
4
+ BASE_TICK_MS,
5
+ changeDirection,
6
+ createInitialState,
7
+ GAME_HEIGHT,
8
+ GAME_WIDTH,
9
+ MIN_TICK_MS,
10
+ SCORE_PER_FOOD,
11
+ spawnFood,
12
+ tickIntervalForScore,
13
+ type GameState,
14
+ type Point,
15
+ } from "./game.ts";
16
+
17
+ const fixedRng = (value: number) => () => value;
18
+
19
+ describe("createInitialState", () => {
20
+ it("places a 3-segment snake near the centre, starting direction right, status starting", () => {
21
+ const state = createInitialState(fixedRng(0));
22
+ expect(state.snake).toHaveLength(3);
23
+ expect(state.snake[0]).toEqual({ x: GAME_WIDTH / 2, y: Math.floor(GAME_HEIGHT / 2) });
24
+ expect(state.direction).toBe("right");
25
+ expect(state.nextDirection).toBe("right");
26
+ expect(state.status).toBe("starting");
27
+ expect(state.score).toBe(0);
28
+ expect(state.food).not.toBeNull();
29
+ });
30
+ });
31
+
32
+ describe("spawnFood", () => {
33
+ it("returns a free cell that is not on the snake", () => {
34
+ const snake: Point[] = [
35
+ { x: 0, y: 0 },
36
+ { x: 1, y: 0 },
37
+ ];
38
+ const food = spawnFood(snake, fixedRng(0));
39
+ expect(food).not.toBeNull();
40
+ expect(snake.some((p) => p.x === food!.x && p.y === food!.y)).toBe(false);
41
+ });
42
+
43
+ it("returns null when the board is fully occupied", () => {
44
+ const snake: Point[] = [];
45
+ for (let y = 0; y < GAME_HEIGHT; y++) {
46
+ for (let x = 0; x < GAME_WIDTH; x++) {
47
+ snake.push({ x, y });
48
+ }
49
+ }
50
+ expect(spawnFood(snake, fixedRng(0))).toBeNull();
51
+ });
52
+
53
+ it("respects the rng for deterministic placement", () => {
54
+ const snake: Point[] = [{ x: 0, y: 0 }];
55
+ const a = spawnFood(snake, fixedRng(0));
56
+ const b = spawnFood(snake, fixedRng(0));
57
+ expect(a).toEqual(b);
58
+ });
59
+ });
60
+
61
+ describe("tickIntervalForScore", () => {
62
+ it("uses BASE_TICK_MS at score 0", () => {
63
+ expect(tickIntervalForScore(0)).toBe(BASE_TICK_MS);
64
+ });
65
+
66
+ it("ramps linearly downward with score", () => {
67
+ expect(tickIntervalForScore(40)).toBe(80);
68
+ expect(tickIntervalForScore(80)).toBe(60);
69
+ });
70
+
71
+ it("clamps at MIN_TICK_MS", () => {
72
+ expect(tickIntervalForScore(200)).toBe(MIN_TICK_MS);
73
+ expect(tickIntervalForScore(10_000)).toBe(MIN_TICK_MS);
74
+ });
75
+ });
76
+
77
+ describe("changeDirection", () => {
78
+ function startingState(): GameState {
79
+ return createInitialState(fixedRng(0));
80
+ }
81
+
82
+ it("transitions starting → running on first directional input", () => {
83
+ const next = changeDirection(startingState(), "up");
84
+ expect(next.status).toBe("running");
85
+ expect(next.nextDirection).toBe("up");
86
+ });
87
+
88
+ it("rejects 180-degree turn (right snake cannot become left)", () => {
89
+ const state = startingState();
90
+ const next = changeDirection(state, "left");
91
+ expect(next).toBe(state);
92
+ });
93
+
94
+ it("ignores input on gameover/won", () => {
95
+ const over: GameState = { ...startingState(), status: "gameover" };
96
+ expect(changeDirection(over, "up")).toBe(over);
97
+ const won: GameState = { ...startingState(), status: "won" };
98
+ expect(changeDirection(won, "up")).toBe(won);
99
+ });
100
+
101
+ it("preserves status when already running", () => {
102
+ const running: GameState = { ...startingState(), status: "running" };
103
+ const next = changeDirection(running, "up");
104
+ expect(next.status).toBe("running");
105
+ });
106
+ });
107
+
108
+ describe("applyTick", () => {
109
+ function running(overrides: Partial<GameState> = {}): GameState {
110
+ return { ...createInitialState(fixedRng(0)), status: "running", ...overrides };
111
+ }
112
+
113
+ it("does nothing when status is not running", () => {
114
+ const state = createInitialState(fixedRng(0));
115
+ expect(applyTick(state)).toBe(state);
116
+ });
117
+
118
+ it("moves the snake by one cell in the current direction", () => {
119
+ const state = running();
120
+ const headBefore = state.snake[0];
121
+ const next = applyTick(state, fixedRng(0));
122
+ expect(next.snake[0]).toEqual({ x: headBefore.x + 1, y: headBefore.y });
123
+ expect(next.snake).toHaveLength(state.snake.length);
124
+ });
125
+
126
+ it("triggers gameover on wall collision", () => {
127
+ const state = running({
128
+ snake: [{ x: GAME_WIDTH - 1, y: 0 }],
129
+ direction: "right",
130
+ nextDirection: "right",
131
+ food: { x: 0, y: 0 },
132
+ });
133
+ const next = applyTick(state, fixedRng(0));
134
+ expect(next.status).toBe("gameover");
135
+ });
136
+
137
+ it("triggers gameover on self collision", () => {
138
+ const state = running({
139
+ snake: [
140
+ { x: 5, y: 5 },
141
+ { x: 6, y: 5 },
142
+ { x: 6, y: 6 },
143
+ { x: 5, y: 6 },
144
+ ],
145
+ direction: "down",
146
+ nextDirection: "down",
147
+ food: { x: 0, y: 0 },
148
+ });
149
+ const next = applyTick(state, fixedRng(0));
150
+ expect(next.status).toBe("gameover");
151
+ });
152
+
153
+ it("grows the snake and increments score when eating food", () => {
154
+ const state = running({
155
+ snake: [
156
+ { x: 5, y: 5 },
157
+ { x: 4, y: 5 },
158
+ ],
159
+ direction: "right",
160
+ nextDirection: "right",
161
+ food: { x: 6, y: 5 },
162
+ score: 0,
163
+ });
164
+ const next = applyTick(state, fixedRng(0));
165
+ expect(next.snake).toHaveLength(3);
166
+ expect(next.snake[0]).toEqual({ x: 6, y: 5 });
167
+ expect(next.score).toBe(SCORE_PER_FOOD);
168
+ expect(next.food).not.toBeNull();
169
+ });
170
+
171
+ it("triggers won when eating fills the board", () => {
172
+ const allCells: Point[] = [];
173
+ for (let y = 0; y < GAME_HEIGHT; y++) {
174
+ for (let x = 0; x < GAME_WIDTH; x++) allCells.push({ x, y });
175
+ }
176
+ const food = allCells[allCells.length - 1];
177
+ const snake = allCells.slice(0, -1).reverse();
178
+ const head = snake[0];
179
+ const dx = food.x - head.x;
180
+ const dy = food.y - head.y;
181
+ let direction: GameState["direction"] = "right";
182
+ if (dx === 1) direction = "right";
183
+ else if (dx === -1) direction = "left";
184
+ else if (dy === 1) direction = "down";
185
+ else if (dy === -1) direction = "up";
186
+ const state: GameState = {
187
+ snake,
188
+ food,
189
+ direction,
190
+ nextDirection: direction,
191
+ score: 0,
192
+ status: "running",
193
+ };
194
+ const next = applyTick(state, fixedRng(0));
195
+ expect(next.status).toBe("won");
196
+ expect(next.food).toBeNull();
197
+ });
198
+
199
+ it("applies the queued nextDirection (90-degree turn between ticks)", () => {
200
+ const state = running({
201
+ snake: [
202
+ { x: 5, y: 5 },
203
+ { x: 4, y: 5 },
204
+ ],
205
+ direction: "right",
206
+ nextDirection: "up",
207
+ food: { x: 0, y: 0 },
208
+ });
209
+ const next = applyTick(state, fixedRng(0));
210
+ expect(next.snake[0]).toEqual({ x: 5, y: 4 });
211
+ expect(next.direction).toBe("up");
212
+ });
213
+
214
+ it.each([
215
+ ["up", { x: 5, y: 4 }],
216
+ ["down", { x: 5, y: 6 }],
217
+ ["left", { x: 4, y: 5 }],
218
+ ["right", { x: 6, y: 5 }],
219
+ ] as const)("moves correctly for direction %s", (direction, expectedHead) => {
220
+ const state = running({
221
+ snake: [{ x: 5, y: 5 }],
222
+ direction,
223
+ nextDirection: direction,
224
+ food: { x: 0, y: 0 },
225
+ });
226
+ const next = applyTick(state, fixedRng(0));
227
+ expect(next.snake[0]).toEqual(expectedHead);
228
+ });
229
+ });
@@ -0,0 +1,119 @@
1
+ export type Direction = "up" | "down" | "left" | "right";
2
+
3
+ export interface Point {
4
+ x: number;
5
+ y: number;
6
+ }
7
+
8
+ export const GAME_WIDTH = 40;
9
+ export const GAME_HEIGHT = 15;
10
+ export const BASE_TICK_MS = 100;
11
+ export const MIN_TICK_MS = 40;
12
+ export const SPEED_RAMP_PER_POINT = 0.5;
13
+ export const SCORE_PER_FOOD = 10;
14
+
15
+ export type GameStatus = "starting" | "running" | "gameover" | "won";
16
+
17
+ export interface GameState {
18
+ snake: Point[];
19
+ food: Point | null;
20
+ direction: Direction;
21
+ nextDirection: Direction;
22
+ score: number;
23
+ status: GameStatus;
24
+ }
25
+
26
+ const OPPOSITE: Record<Direction, Direction> = {
27
+ up: "down",
28
+ down: "up",
29
+ left: "right",
30
+ right: "left",
31
+ };
32
+
33
+ export type Rng = () => number;
34
+
35
+ export function spawnFood(snake: Point[], rng: Rng = Math.random): Point | null {
36
+ const occupied = new Set(snake.map((p) => `${p.x},${p.y}`));
37
+ const free: Point[] = [];
38
+ for (let y = 0; y < GAME_HEIGHT; y++) {
39
+ for (let x = 0; x < GAME_WIDTH; x++) {
40
+ if (!occupied.has(`${x},${y}`)) free.push({ x, y });
41
+ }
42
+ }
43
+ if (free.length === 0) return null;
44
+ return free[Math.floor(rng() * free.length)];
45
+ }
46
+
47
+ export function createInitialState(rng: Rng = Math.random): GameState {
48
+ const startX = Math.floor(GAME_WIDTH / 2);
49
+ const startY = Math.floor(GAME_HEIGHT / 2);
50
+ const snake: Point[] = [
51
+ { x: startX, y: startY },
52
+ { x: startX - 1, y: startY },
53
+ { x: startX - 2, y: startY },
54
+ ];
55
+ return {
56
+ snake,
57
+ food: spawnFood(snake, rng),
58
+ direction: "right",
59
+ nextDirection: "right",
60
+ score: 0,
61
+ status: "starting",
62
+ };
63
+ }
64
+
65
+ export function tickIntervalForScore(score: number): number {
66
+ const speed = BASE_TICK_MS - score * SPEED_RAMP_PER_POINT;
67
+ return Math.max(MIN_TICK_MS, speed);
68
+ }
69
+
70
+ export function changeDirection(state: GameState, requested: Direction): GameState {
71
+ if (state.status === "gameover" || state.status === "won") return state;
72
+ if (state.direction === OPPOSITE[requested]) return state;
73
+ return {
74
+ ...state,
75
+ nextDirection: requested,
76
+ status: state.status === "starting" ? "running" : state.status,
77
+ };
78
+ }
79
+
80
+ export function applyTick(state: GameState, rng: Rng = Math.random): GameState {
81
+ if (state.status !== "running") return state;
82
+ const direction = state.nextDirection;
83
+ const head = state.snake[0];
84
+ const newHead: Point =
85
+ direction === "up"
86
+ ? { x: head.x, y: head.y - 1 }
87
+ : direction === "down"
88
+ ? { x: head.x, y: head.y + 1 }
89
+ : direction === "left"
90
+ ? { x: head.x - 1, y: head.y }
91
+ : { x: head.x + 1, y: head.y };
92
+
93
+ if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
94
+ return { ...state, direction, status: "gameover" };
95
+ }
96
+ if (state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
97
+ return { ...state, direction, status: "gameover" };
98
+ }
99
+
100
+ const ate = state.food !== null && state.food.x === newHead.x && state.food.y === newHead.y;
101
+ const newSnake = ate ? [newHead, ...state.snake] : [newHead, ...state.snake.slice(0, -1)];
102
+ if (ate) {
103
+ const nextFood = spawnFood(newSnake, rng);
104
+ return {
105
+ ...state,
106
+ snake: newSnake,
107
+ food: nextFood,
108
+ direction,
109
+ score: state.score + SCORE_PER_FOOD,
110
+ status: nextFood === null ? "won" : "running",
111
+ };
112
+ }
113
+ return {
114
+ ...state,
115
+ snake: newSnake,
116
+ direction,
117
+ status: "running",
118
+ };
119
+ }
@@ -0,0 +1,17 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { AstroSnakeComponent } from "./component.ts";
3
+
4
+ export default function astroSnakeExtension(pi: ExtensionAPI): void {
5
+ pi.registerCommand("snake", {
6
+ description: "Play Astro Snake",
7
+ handler: async (_args, ctx) => {
8
+ if (!ctx.hasUI) {
9
+ ctx.ui.notify("Astro Snake requires interactive mode.", "error");
10
+ return;
11
+ }
12
+ await ctx.ui.custom((tui, theme, _kb, done) => {
13
+ return new AstroSnakeComponent(tui, theme, () => done(undefined));
14
+ });
15
+ },
16
+ });
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Personal pi customizations (extensions, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -1,77 +0,0 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
-
6
- type Handler = (event: { systemPrompt: string }) => Promise<{ systemPrompt: string } | undefined>;
7
-
8
- interface CapturedPi {
9
- on: (event: string, handler: Handler) => void;
10
- handlers: Record<string, Handler>;
11
- }
12
-
13
- function makePi(): CapturedPi {
14
- const handlers: Record<string, Handler> = {};
15
- return {
16
- handlers,
17
- on: (event, handler) => {
18
- handlers[event] = handler;
19
- },
20
- };
21
- }
22
-
23
- describe("claude-globals extension", () => {
24
- let fakeHome: string;
25
- const homeEnv = process.env.HOME;
26
-
27
- beforeEach(() => {
28
- fakeHome = mkdtempSync(join(tmpdir(), "claude-glob-"));
29
- process.env.HOME = fakeHome;
30
- vi.resetModules();
31
- });
32
-
33
- afterEach(() => {
34
- rmSync(fakeHome, { recursive: true, force: true });
35
- process.env.HOME = homeEnv;
36
- });
37
-
38
- it("appends ~/.claude/CLAUDE.md content inside a wrapper tag", async () => {
39
- const claudeDir = join(fakeHome, ".claude");
40
- const { mkdirSync } = await import("node:fs");
41
- mkdirSync(claudeDir, { recursive: true });
42
- writeFileSync(join(claudeDir, "CLAUDE.md"), "RULE ONE\nRULE TWO", "utf-8");
43
-
44
- const mod = await import("./index.ts");
45
- const pi = makePi();
46
- mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
47
-
48
- const result = await pi.handlers.before_agent_start({ systemPrompt: "SYS" });
49
- expect(result).toBeDefined();
50
- expect(result?.systemPrompt).toContain("SYS");
51
- expect(result?.systemPrompt).toContain("<claude-global-rules");
52
- expect(result?.systemPrompt).toContain("RULE ONE");
53
- expect(result?.systemPrompt).toContain("RULE TWO");
54
- expect(result?.systemPrompt).toContain("</claude-global-rules>");
55
- });
56
-
57
- it("returns undefined when ~/.claude/CLAUDE.md does not exist", async () => {
58
- const mod = await import("./index.ts");
59
- const pi = makePi();
60
- mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
61
- const result = await pi.handlers.before_agent_start({ systemPrompt: "SYS" });
62
- expect(result).toBeUndefined();
63
- });
64
-
65
- it("returns undefined when ~/.claude/CLAUDE.md is empty / whitespace", async () => {
66
- const claudeDir = join(fakeHome, ".claude");
67
- const { mkdirSync } = await import("node:fs");
68
- mkdirSync(claudeDir, { recursive: true });
69
- writeFileSync(join(claudeDir, "CLAUDE.md"), " \n\n ", "utf-8");
70
-
71
- const mod = await import("./index.ts");
72
- const pi = makePi();
73
- mod.default(pi as unknown as Parameters<typeof mod.default>[0]);
74
- const result = await pi.handlers.before_agent_start({ systemPrompt: "SYS" });
75
- expect(result).toBeUndefined();
76
- });
77
- });
@@ -1,17 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
-
6
- const CLAUDE_GLOBAL_PATH = path.join(os.homedir(), ".claude", "CLAUDE.md");
7
-
8
- export default function claudeGlobalsExtension(pi: ExtensionAPI): void {
9
- pi.on("before_agent_start", async (event) => {
10
- if (!fs.existsSync(CLAUDE_GLOBAL_PATH)) return;
11
- const content = fs.readFileSync(CLAUDE_GLOBAL_PATH, "utf-8").trim();
12
- if (!content) return;
13
- return {
14
- systemPrompt: `${event.systemPrompt}\n\n<claude-global-rules source="~/.claude/CLAUDE.md">\n${content}\n</claude-global-rules>`,
15
- };
16
- });
17
- }