@pi-extensions/pi-pacman 0.1.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/LICENSE +21 -0
- package/README.md +68 -0
- package/index.ts +518 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Saeed Marzban
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the Software), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# pi-pacman
|
|
2
|
+
|
|
3
|
+
Pac-Man working indicator for [pi](https://github.com/earendil-works/pi).
|
|
4
|
+
|
|
5
|
+
Replaces the streaming spinner with pellet runs, ghost chases, arcade tunnels, and fruit bonuses.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# from npm (after publish)
|
|
11
|
+
pi install npm:@pi-extensions/pi-pacman
|
|
12
|
+
|
|
13
|
+
# local path (this monorepo)
|
|
14
|
+
pi install /absolute/path/to/pi-extentions/packages/pi-pacman
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Publish to npm
|
|
18
|
+
|
|
19
|
+
Releases are **tag-driven** (OIDC trusted publishing) — see [docs/releases.md](../../docs/releases.md).
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# after version bump on main:
|
|
23
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
One-time: reservation publish with OTP + Trusted Publisher on npmjs.com (same doc).
|
|
27
|
+
|
|
28
|
+
Then anyone can:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install npm:@pi-extensions/pi-pacman
|
|
32
|
+
pi install npm:@pi-extensions/pi-pacman@0.1.0
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Commands
|
|
36
|
+
|
|
37
|
+
| Command | Effect |
|
|
38
|
+
|---------|--------|
|
|
39
|
+
| `/pacman` | Show current look |
|
|
40
|
+
| `/pacman list` | Catalog of looks |
|
|
41
|
+
| `/pacman <look>` | Lock to that look (stops rotate) |
|
|
42
|
+
| `/pacman rotate` | Cycle a different look every message |
|
|
43
|
+
| `/pacman off` | Hide the indicator (stops rotate) |
|
|
44
|
+
| `/pacman message <text>` | Custom working message |
|
|
45
|
+
| `/pacman message` | Reset message to look default |
|
|
46
|
+
| `/pacman clear` | Hide the look list widget |
|
|
47
|
+
|
|
48
|
+
### Looks
|
|
49
|
+
|
|
50
|
+
| Look | Vibe |
|
|
51
|
+
|------|------|
|
|
52
|
+
| `classic` | Full-width pellet run (default) |
|
|
53
|
+
| `chase` | Full-width Blinky hunt → revenge |
|
|
54
|
+
| `mini` | 7-cell pellet run |
|
|
55
|
+
| `arcade` | 7-cell maze tunnel |
|
|
56
|
+
| `fruit` | 7-cell cherry bonus |
|
|
57
|
+
|
|
58
|
+
Speeds: full-width **80ms**/frame · fixed **110ms**/frame.
|
|
59
|
+
|
|
60
|
+
## Persistence
|
|
61
|
+
|
|
62
|
+
Look, rotate on/off, rotate index, and custom message save to `~/.pi/agent/pacman-thinking.json`.
|
|
63
|
+
|
|
64
|
+
## One-shot (without install)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pi --extension ./index.ts
|
|
68
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pac-Man Thinking Extension
|
|
3
|
+
*
|
|
4
|
+
* Replaces pi's streaming working indicator with a Pac-Man animation.
|
|
5
|
+
*
|
|
6
|
+
* Install:
|
|
7
|
+
* pi install /path/to/pi-extentions/packages/pi-pacman
|
|
8
|
+
* pi --extension ./index.ts
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* /pacman Show current mode
|
|
12
|
+
* /pacman list List all looks
|
|
13
|
+
* /pacman <look> Lock to one look (stops rotate)
|
|
14
|
+
* /pacman rotate Cycle a different look on every message
|
|
15
|
+
* /pacman off Hide indicator (also stops rotate)
|
|
16
|
+
* /pacman message ... Custom working message (empty = mode default)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import type {
|
|
22
|
+
ExtensionAPI,
|
|
23
|
+
ExtensionContext,
|
|
24
|
+
ExtensionUIContext,
|
|
25
|
+
WorkingIndicatorOptions,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
|
|
29
|
+
// ── palette ──────────────────────────────────────────────────────────
|
|
30
|
+
const RESET = "\x1b[39m";
|
|
31
|
+
const YELLOW = "\x1b[38;2;255;221;0m";
|
|
32
|
+
const PELLET = "\x1b[38;2;255;184;174m";
|
|
33
|
+
const POWER = "\x1b[38;2;255;184;255m";
|
|
34
|
+
const WALL = "\x1b[38;2;33;33;255m";
|
|
35
|
+
const CHERRY = "\x1b[38;2;255;0;0m";
|
|
36
|
+
const SCORE = "\x1b[38;2;255;255;255m";
|
|
37
|
+
const GHOSTS = {
|
|
38
|
+
blinky: "\x1b[38;2;255;0;0m",
|
|
39
|
+
scared: "\x1b[38;2;33;33;255m",
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/** Full-width looks (classic / chase) — snappier chomp. */
|
|
43
|
+
const FRAME_MS_FULL = 80;
|
|
44
|
+
/** Fixed 7-cell looks (mini / arcade / fruit) — a bit slower. */
|
|
45
|
+
const FRAME_MS_FIXED = 110;
|
|
46
|
+
|
|
47
|
+
type Facing = "right" | "left";
|
|
48
|
+
|
|
49
|
+
interface Look {
|
|
50
|
+
id: string;
|
|
51
|
+
blurb: string;
|
|
52
|
+
message: string;
|
|
53
|
+
/** `track` = number of cells across the strip. */
|
|
54
|
+
frames: (track: number) => string[];
|
|
55
|
+
fullWidth?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function paint(color: string, text: string): string {
|
|
59
|
+
return `${color}${text}${RESET}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pac(open: boolean, facing: Facing = "right"): string {
|
|
63
|
+
if (!open) return paint(YELLOW, "○"); // empty circle = closed mouth
|
|
64
|
+
return paint(YELLOW, facing === "right" ? "ᗧ" : "ᗤ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pellet(): string {
|
|
68
|
+
return paint(PELLET, "·");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ghost(color: string): string {
|
|
72
|
+
return paint(color, "ᗣ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function power(): string {
|
|
76
|
+
return paint(POWER, "○");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function flash(): string {
|
|
80
|
+
return paint(YELLOW, "✦");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cherry(): string {
|
|
84
|
+
return paint(CHERRY, "♦");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function termCols(): number {
|
|
88
|
+
const cols = process.stdout.columns || Number(process.env.COLUMNS) || 80;
|
|
89
|
+
return Math.max(20, cols);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Visible width budget for the indicator strip (cells). */
|
|
93
|
+
function indicatorWidth(message: string): number {
|
|
94
|
+
const reserve = Math.max(message.length, 12) + 3;
|
|
95
|
+
return Math.max(8, termCols() - reserve);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Pac-Man walks right eating pellets across `track` cells, then left. */
|
|
99
|
+
function runTrack(track: number): string[] {
|
|
100
|
+
const frames: string[] = [];
|
|
101
|
+
const last = Math.max(1, track - 1);
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i <= last; i++) {
|
|
104
|
+
for (const open of [true, false]) {
|
|
105
|
+
let row = "";
|
|
106
|
+
for (let c = 0; c <= last; c++) {
|
|
107
|
+
if (c === i) row += pac(open, "right");
|
|
108
|
+
else if (c > i) row += pellet();
|
|
109
|
+
else row += " ";
|
|
110
|
+
}
|
|
111
|
+
frames.push(row);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (let i = last; i >= 0; i--) {
|
|
115
|
+
for (const open of [true, false]) {
|
|
116
|
+
let row = "";
|
|
117
|
+
for (let c = 0; c <= last; c++) {
|
|
118
|
+
if (c === i) row += pac(open, "left");
|
|
119
|
+
else if (c < i) row += pellet();
|
|
120
|
+
else row += " ";
|
|
121
|
+
}
|
|
122
|
+
frames.push(row);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return frames;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── looks ────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** Fixed strip length for mini / arcade / fruit (cells). */
|
|
131
|
+
const FIXED_CELLS = 7;
|
|
132
|
+
|
|
133
|
+
const LOOKS: Look[] = [
|
|
134
|
+
{
|
|
135
|
+
id: "classic",
|
|
136
|
+
blurb: "Full-width pellet run — dots span the terminal",
|
|
137
|
+
message: "waka waka...",
|
|
138
|
+
fullWidth: true,
|
|
139
|
+
frames: (track) => runTrack(track),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "chase",
|
|
143
|
+
blurb: "Full-width: Blinky hunts → power pellet → revenge",
|
|
144
|
+
message: "run from blinky...",
|
|
145
|
+
fullWidth: true,
|
|
146
|
+
frames: (track) => {
|
|
147
|
+
const last = Math.max(6, track - 1);
|
|
148
|
+
const gap = Math.max(2, Math.min(5, Math.floor(last / 10)));
|
|
149
|
+
const frames: string[] = [];
|
|
150
|
+
|
|
151
|
+
const rowAt = (
|
|
152
|
+
pacPos: number,
|
|
153
|
+
open: boolean,
|
|
154
|
+
facing: Facing,
|
|
155
|
+
ghostPos: number | null,
|
|
156
|
+
ghostColor: string,
|
|
157
|
+
opts?: { powerAt?: number; flash?: boolean },
|
|
158
|
+
) => {
|
|
159
|
+
let row = "";
|
|
160
|
+
for (let c = 0; c <= last; c++) {
|
|
161
|
+
if (opts?.flash && c === pacPos) row += flash();
|
|
162
|
+
else if (c === pacPos) row += pac(open, facing);
|
|
163
|
+
else if (ghostPos !== null && c === ghostPos) row += ghost(ghostColor);
|
|
164
|
+
else if (opts?.powerAt === c) row += power();
|
|
165
|
+
else if (facing === "right" && c > pacPos) row += pellet();
|
|
166
|
+
else if (facing === "left" && c < pacPos) row += pellet();
|
|
167
|
+
else row += " ";
|
|
168
|
+
}
|
|
169
|
+
return row;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (let i = gap; i <= last - 1; i++) {
|
|
173
|
+
for (const open of [true, false]) {
|
|
174
|
+
frames.push(rowAt(i, open, "right", i - gap, GHOSTS.blinky));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
frames.push(rowAt(last - 1, true, "right", last - 1 - gap, GHOSTS.blinky, { powerAt: last }));
|
|
179
|
+
frames.push(rowAt(last - 1, false, "right", last - 1 - gap, GHOSTS.blinky, { powerAt: last }));
|
|
180
|
+
frames.push(rowAt(last, true, "right", last - gap, GHOSTS.blinky, { flash: true }));
|
|
181
|
+
frames.push(rowAt(last, false, "right", last - gap, GHOSTS.scared, { flash: true }));
|
|
182
|
+
|
|
183
|
+
for (let i = last; i >= 0; i--) {
|
|
184
|
+
for (const open of [true, false]) {
|
|
185
|
+
const gPos = i - gap;
|
|
186
|
+
frames.push(rowAt(i, open, "left", gPos >= 0 ? gPos : null, GHOSTS.scared));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
frames.push(rowAt(0, true, "left", null, GHOSTS.scared, { flash: true }));
|
|
190
|
+
frames.push(" ".repeat(last + 1));
|
|
191
|
+
return frames;
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: "mini",
|
|
196
|
+
blurb: "7-cell pellet run",
|
|
197
|
+
message: "chomp chomp...",
|
|
198
|
+
frames: () => runTrack(FIXED_CELLS),
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "arcade",
|
|
202
|
+
blurb: "7-cell maze tunnel with blue walls",
|
|
203
|
+
message: "insert coin...",
|
|
204
|
+
frames: () => {
|
|
205
|
+
const last = FIXED_CELLS - 1;
|
|
206
|
+
const frames: string[] = [];
|
|
207
|
+
const wall = paint(WALL, "│");
|
|
208
|
+
for (let i = 0; i <= last; i++) {
|
|
209
|
+
for (const open of [true, false]) {
|
|
210
|
+
let mid = "";
|
|
211
|
+
for (let c = 0; c <= last; c++) {
|
|
212
|
+
if (c === i) mid += pac(open, "right");
|
|
213
|
+
else if (c > i) mid += pellet();
|
|
214
|
+
else mid += " ";
|
|
215
|
+
}
|
|
216
|
+
frames.push(`${wall}${mid}${wall}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
frames.push(`${wall}${paint(WALL, "≈".repeat(FIXED_CELLS))}${wall}`);
|
|
220
|
+
for (let i = last; i >= 0; i--) {
|
|
221
|
+
for (const open of [true, false]) {
|
|
222
|
+
let mid = "";
|
|
223
|
+
for (let c = 0; c <= last; c++) {
|
|
224
|
+
if (c === i) mid += pac(open, "left");
|
|
225
|
+
else if (c < i) mid += pellet();
|
|
226
|
+
else mid += " ";
|
|
227
|
+
}
|
|
228
|
+
frames.push(`${wall}${mid}${wall}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return frames;
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "fruit",
|
|
236
|
+
blurb: "7-cell cherry bonus run",
|
|
237
|
+
message: "fruit bonus...",
|
|
238
|
+
frames: () => {
|
|
239
|
+
const last = FIXED_CELLS - 1;
|
|
240
|
+
const frames: string[] = [];
|
|
241
|
+
|
|
242
|
+
// Rightward: cherry waits at the far end
|
|
243
|
+
for (let i = 0; i <= last; i++) {
|
|
244
|
+
for (const open of [true, false]) {
|
|
245
|
+
let row = "";
|
|
246
|
+
for (let c = 0; c <= last; c++) {
|
|
247
|
+
if (c === i && i === last && !open) row += flash();
|
|
248
|
+
else if (c === i) row += pac(open, "right");
|
|
249
|
+
else if (c === last && i < last) row += cherry();
|
|
250
|
+
else if (c > i) row += pellet();
|
|
251
|
+
else row += " ";
|
|
252
|
+
}
|
|
253
|
+
frames.push(row);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
frames.push(`${" ".repeat(Math.max(0, FIXED_CELLS - 3))}${paint(SCORE, "100")}`);
|
|
257
|
+
frames.push(`${" ".repeat(Math.max(0, FIXED_CELLS - 3))}${paint(SCORE, "100")}`);
|
|
258
|
+
|
|
259
|
+
// Leftward: cherry waits at the start
|
|
260
|
+
for (let i = last; i >= 0; i--) {
|
|
261
|
+
for (const open of [true, false]) {
|
|
262
|
+
let row = "";
|
|
263
|
+
for (let c = 0; c <= last; c++) {
|
|
264
|
+
if (c === i && i === 0 && !open) row += flash();
|
|
265
|
+
else if (c === i) row += pac(open, "left");
|
|
266
|
+
else if (c === 0 && i > 0) row += cherry();
|
|
267
|
+
else if (c < i) row += pellet();
|
|
268
|
+
else row += " ";
|
|
269
|
+
}
|
|
270
|
+
frames.push(row);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
frames.push(`${paint(SCORE, "300")}${" ".repeat(Math.max(0, FIXED_CELLS - 3))}`);
|
|
274
|
+
frames.push(`${paint(SCORE, "300")}${" ".repeat(Math.max(0, FIXED_CELLS - 3))}`);
|
|
275
|
+
return frames;
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
const LOOK_BY_ID = new Map(LOOKS.map((l) => [l.id, l]));
|
|
281
|
+
/** Looks eligible for rotate/random (excludes off/default). */
|
|
282
|
+
const ROTATE_LOOKS = LOOKS.map((l) => l.id);
|
|
283
|
+
|
|
284
|
+
type Mode = string;
|
|
285
|
+
|
|
286
|
+
interface PersistedState {
|
|
287
|
+
mode?: string;
|
|
288
|
+
/** @deprecated old field — migrated to `rotate` */
|
|
289
|
+
mix?: string;
|
|
290
|
+
rotate?: boolean;
|
|
291
|
+
rotateIndex?: number;
|
|
292
|
+
customMessage?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function statePath(): string {
|
|
296
|
+
return join(getAgentDir(), "pacman-thinking.json");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function loadState(): PersistedState {
|
|
300
|
+
const path = statePath();
|
|
301
|
+
if (!existsSync(path)) return {};
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(readFileSync(path, "utf-8")) as PersistedState;
|
|
304
|
+
} catch {
|
|
305
|
+
return {};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function saveState(state: PersistedState): void {
|
|
310
|
+
const path = statePath();
|
|
311
|
+
try {
|
|
312
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
313
|
+
writeFileSync(path, `${JSON.stringify(state, null, "\t")}\n`, "utf-8");
|
|
314
|
+
} catch {
|
|
315
|
+
// Non-fatal — indicator still works without persistence
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function resolveTrack(look: Look, message: string): number {
|
|
320
|
+
if (look.fullWidth) return indicatorWidth(message);
|
|
321
|
+
return FIXED_CELLS;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function getIndicator(mode: Mode, message: string): WorkingIndicatorOptions | undefined {
|
|
325
|
+
if (mode === "off") return { frames: [] };
|
|
326
|
+
const look = LOOK_BY_ID.get(mode);
|
|
327
|
+
if (!look) return undefined;
|
|
328
|
+
const track = resolveTrack(look, message);
|
|
329
|
+
const intervalMs = look.fullWidth ? FRAME_MS_FULL : FRAME_MS_FIXED;
|
|
330
|
+
return { frames: look.frames(track), intervalMs };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function describeMode(mode: Mode): string {
|
|
334
|
+
if (mode === "off") return "hidden";
|
|
335
|
+
const look = LOOK_BY_ID.get(mode);
|
|
336
|
+
return look ? `${look.id} — ${look.blurb}` : mode;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function defaultMessageFor(mode: Mode): string | undefined {
|
|
340
|
+
if (mode === "off") return " ";
|
|
341
|
+
return LOOK_BY_ID.get(mode)?.message;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function listLooks(): string {
|
|
345
|
+
const lines = LOOKS.map((l) => {
|
|
346
|
+
const tag = l.fullWidth ? " (full width)" : "";
|
|
347
|
+
return ` ${l.id.padEnd(8)} ${l.blurb}${tag}`;
|
|
348
|
+
});
|
|
349
|
+
return [
|
|
350
|
+
"Pac-Man looks:",
|
|
351
|
+
...lines,
|
|
352
|
+
"",
|
|
353
|
+
" rotate Different look each message (cycle)",
|
|
354
|
+
" off Hide indicator",
|
|
355
|
+
"",
|
|
356
|
+
"Pick a look name to lock it (stops rotate).",
|
|
357
|
+
].join("\n");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export default function (pi: ExtensionAPI) {
|
|
361
|
+
const saved = loadState();
|
|
362
|
+
// Migrate old persisted values
|
|
363
|
+
const savedMode =
|
|
364
|
+
saved.mode === "default" || saved.mode === "random" || saved.mode === "score"
|
|
365
|
+
? "classic"
|
|
366
|
+
: saved.mode;
|
|
367
|
+
let mode: Mode =
|
|
368
|
+
savedMode && (LOOK_BY_ID.has(savedMode) || savedMode === "off") ? savedMode : "classic";
|
|
369
|
+
let rotate =
|
|
370
|
+
typeof saved.rotate === "boolean"
|
|
371
|
+
? saved.rotate
|
|
372
|
+
: saved.mix === "rotate"; // migrate old mix field
|
|
373
|
+
let rotateIndex =
|
|
374
|
+
typeof saved.rotateIndex === "number" && saved.rotateIndex >= 0 ? saved.rotateIndex : 0;
|
|
375
|
+
let customMessage: string | undefined =
|
|
376
|
+
typeof saved.customMessage === "string" && saved.customMessage.length > 0
|
|
377
|
+
? saved.customMessage
|
|
378
|
+
: undefined;
|
|
379
|
+
let lastUi: ExtensionUIContext | undefined;
|
|
380
|
+
|
|
381
|
+
const persist = () => {
|
|
382
|
+
saveState({
|
|
383
|
+
mode,
|
|
384
|
+
rotate,
|
|
385
|
+
rotateIndex,
|
|
386
|
+
customMessage,
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const statusLabel = () => {
|
|
391
|
+
if (mode === "off") return `ᗧ off`;
|
|
392
|
+
return `ᗧ ${mode}${rotate ? " ↻" : ""}`;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const apply = (ctx: ExtensionContext) => {
|
|
396
|
+
lastUi = ctx.ui;
|
|
397
|
+
const message = customMessage ?? defaultMessageFor(mode) ?? "Working...";
|
|
398
|
+
ctx.ui.setWorkingIndicator(getIndicator(mode, message));
|
|
399
|
+
ctx.ui.setWorkingMessage(mode === "off" ? " " : message);
|
|
400
|
+
ctx.ui.setStatus("pacman-thinking", ctx.ui.theme.fg("dim", statusLabel()));
|
|
401
|
+
persist();
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const isFullWidthMode = () => LOOK_BY_ID.get(mode)?.fullWidth === true;
|
|
405
|
+
|
|
406
|
+
const pickNextRotatedLook = () => {
|
|
407
|
+
mode = ROTATE_LOOKS[rotateIndex % ROTATE_LOOKS.length]!;
|
|
408
|
+
rotateIndex++;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const reapplyIfFullWidth = () => {
|
|
412
|
+
if (!lastUi || !isFullWidthMode()) return;
|
|
413
|
+
const message = customMessage ?? defaultMessageFor(mode) ?? "Working...";
|
|
414
|
+
lastUi.setWorkingIndicator(getIndicator(mode, message));
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
process.stdout.on?.("resize", reapplyIfFullWidth);
|
|
418
|
+
|
|
419
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
420
|
+
// Don't advance rotate here — agent_start picks per message.
|
|
421
|
+
apply(ctx);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
425
|
+
if (rotate) {
|
|
426
|
+
pickNextRotatedLook();
|
|
427
|
+
apply(ctx);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (isFullWidthMode()) apply(ctx);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
pi.registerCommand("pacman", {
|
|
434
|
+
description: "Pac-Man indicator. Try: /pacman list · /pacman rotate",
|
|
435
|
+
handler: async (args, ctx) => {
|
|
436
|
+
const raw = args.trim();
|
|
437
|
+
if (!raw) {
|
|
438
|
+
const msg = customMessage ?? defaultMessageFor(mode) ?? "Working...";
|
|
439
|
+
const extra = isFullWidthMode()
|
|
440
|
+
? ` · width ${indicatorWidth(msg)} cells`
|
|
441
|
+
: LOOK_BY_ID.has(mode)
|
|
442
|
+
? ` · width ${FIXED_CELLS} cells`
|
|
443
|
+
: "";
|
|
444
|
+
const rotInfo = rotate ? " · rotate on" : "";
|
|
445
|
+
ctx.ui.notify(
|
|
446
|
+
`Pac-Man: ${describeMode(mode)}${rotInfo} · message: "${msg}"${extra}`,
|
|
447
|
+
"info",
|
|
448
|
+
);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const [cmd, ...rest] = raw.split(/\s+/);
|
|
453
|
+
const head = cmd!.toLowerCase();
|
|
454
|
+
|
|
455
|
+
if (head === "list" || head === "help" || head === "looks") {
|
|
456
|
+
ctx.ui.setWidget("pacman-looks", listLooks().split("\n"), { placement: "belowEditor" });
|
|
457
|
+
ctx.ui.notify(
|
|
458
|
+
"Looks listed below the editor. /pacman <name> to switch. /pacman clear to hide list.",
|
|
459
|
+
"info",
|
|
460
|
+
);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (head === "clear") {
|
|
465
|
+
ctx.ui.setWidget("pacman-looks", undefined);
|
|
466
|
+
ctx.ui.notify("Cleared look list", "info");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (head === "message") {
|
|
471
|
+
const text = rest.join(" ").trim();
|
|
472
|
+
customMessage = text.length > 0 ? text : undefined;
|
|
473
|
+
apply(ctx);
|
|
474
|
+
ctx.ui.notify(
|
|
475
|
+
customMessage
|
|
476
|
+
? `Working message set to: ${customMessage}`
|
|
477
|
+
: "Working message reset to mode default",
|
|
478
|
+
"info",
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (head === "rotate" || head === "cycle") {
|
|
484
|
+
rotate = true;
|
|
485
|
+
const idx = ROTATE_LOOKS.indexOf(mode);
|
|
486
|
+
rotateIndex = idx >= 0 ? idx : 0;
|
|
487
|
+
pickNextRotatedLook();
|
|
488
|
+
apply(ctx);
|
|
489
|
+
ctx.ui.notify(
|
|
490
|
+
`Pac-Man rotate on — cycles each message (now: ${mode}). Pick a look to lock it.`,
|
|
491
|
+
"info",
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (head === "off" || head === "none" || head === "hide") {
|
|
497
|
+
rotate = false;
|
|
498
|
+
mode = "off";
|
|
499
|
+
ctx.ui.setWidget("pacman-looks", undefined);
|
|
500
|
+
apply(ctx);
|
|
501
|
+
ctx.ui.notify("Pac-Man indicator hidden", "info");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (!LOOK_BY_ID.has(head)) {
|
|
506
|
+
ctx.ui.notify(`Unknown look "${head}". Try /pacman list`, "error");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Picking a look locks it and stops rotate
|
|
511
|
+
rotate = false;
|
|
512
|
+
mode = head;
|
|
513
|
+
ctx.ui.setWidget("pacman-looks", undefined);
|
|
514
|
+
apply(ctx);
|
|
515
|
+
ctx.ui.notify(`Pac-Man indicator: ${describeMode(mode)}`, "info");
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-extensions/pi-pacman",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pac-Man working indicator for pi — replaces the streaming spinner with pellet runs, ghost chases, and more",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pacman",
|
|
10
|
+
"working-indicator",
|
|
11
|
+
"coding-agent"
|
|
12
|
+
],
|
|
13
|
+
"author": "Saeed Marzban",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/smarzban/pi-extentions.git",
|
|
18
|
+
"directory": "packages/pi-pacman"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/smarzban/pi-extentions/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/smarzban/pi-extentions/tree/main/packages/pi-pacman#readme",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.ts",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"prepublishOnly": "node -e \"require('fs').accessSync('index.ts')\""
|
|
42
|
+
}
|
|
43
|
+
}
|