pi-mega-compact 0.8.1 → 0.8.2

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.
@@ -496,6 +496,18 @@ export async function launchDashboardServer(stateDir) {
496
496
  log("server running", { url });
497
497
  // eslint-disable-next-line no-console
498
498
  console.log(`[mega-compact] dashboard server running: ${url}`);
499
+ // v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
500
+ // resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
501
+ // browser hit ::1:port and get connection refused. PREVENT-PI-004
502
+ // (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
503
+ // hosts or a ::1 already in use just skip the mirror.
504
+ let v6;
505
+ const v4Handler = server.listeners("request")[0];
506
+ if (v4Handler) {
507
+ v6 = createServer((r, s) => v4Handler.call(server, r, s));
508
+ v6.on("error", (e) => log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }));
509
+ v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
510
+ }
499
511
  // Write port.pid
500
512
  try {
501
513
  writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
@@ -510,6 +522,10 @@ export async function launchDashboardServer(stateDir) {
510
522
  }
511
523
  catch { /* already gone */ }
512
524
  server.close();
525
+ try {
526
+ v6?.close();
527
+ }
528
+ catch { /* not bound */ }
513
529
  process.exit(0);
514
530
  };
515
531
  process.on("SIGTERM", cleanup);
@@ -40,8 +40,20 @@ async function handleSettings(args, ctx, runtime) {
40
40
  runtime.bindRepo(ctx.cwd);
41
41
  const stateDir = runtime.currentStateDir;
42
42
  const parts = args.trim().split(/\s+/).filter(Boolean);
43
- // bare → print current state.
43
+ // bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
44
+ // static status print when there's no interactive UI (RPC/print mode, or a
45
+ // test harness stubbing only notify). CLI subcommands below still work for
46
+ // power users + scripts.
44
47
  if (parts.length === 0) {
48
+ if (typeof ctx.ui.select === "function") {
49
+ try {
50
+ await runInteractiveMenu(ctx, runtime, stateDir);
51
+ return;
52
+ }
53
+ catch {
54
+ // select threw (non-interactive impl) → fall through to status print
55
+ }
56
+ }
45
57
  const s = getGameState(stateDir);
46
58
  for (const line of fmtState(s))
47
59
  ctx.ui.notify(line);
@@ -105,6 +117,97 @@ async function handleSettings(args, ctx, runtime) {
105
117
  }
106
118
  ctx.ui.notify(`[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`);
107
119
  }
120
+ /** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
121
+ * (a real TUI picker in interactive mode). Loops until the user cancels (or
122
+ * picks "Done"). Each action mutates the global game_state row + bumps the
123
+ * runtime cache so the widget/dashboard reflect it immediately.
124
+ *
125
+ * Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
126
+ * unavailable we fall back to the static fmtState notify print. */
127
+ async function runInteractiveMenu(ctx, runtime, stateDir) {
128
+ for (;;) {
129
+ const s = getGameState(stateDir);
130
+ const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
131
+ const choice = await ctx.ui.select(`[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`, [toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"]);
132
+ if (choice === undefined || choice === "Done")
133
+ return;
134
+ if (choice === toggleLabel) {
135
+ const next = !s.game_mode_on;
136
+ setGameState({ game_mode_on: next }, stateDir);
137
+ runtime.bumpGameState();
138
+ ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
139
+ continue;
140
+ }
141
+ if (choice === "Theme…") {
142
+ await themeSubmenu(ctx, runtime, stateDir);
143
+ continue;
144
+ }
145
+ if (choice === "TUI display mode…") {
146
+ await tuiSubmenu(ctx, runtime, stateDir);
147
+ continue;
148
+ }
149
+ if (choice === "Achievements…") {
150
+ await achievementsView(ctx, stateDir);
151
+ continue;
152
+ }
153
+ }
154
+ }
155
+ /** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
156
+ async function themeSubmenu(ctx, runtime, stateDir) {
157
+ const s = getGameState(stateDir);
158
+ const opts = THEMES.map((t) => {
159
+ const mark = t.id === s.theme ? " ✓" : "";
160
+ return `${t.id}${mark} ${t.label}`;
161
+ });
162
+ opts.push("next (cycle to next theme)");
163
+ opts.push("Back");
164
+ const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
165
+ if (choice === undefined || choice === "Back")
166
+ return;
167
+ const first = choice.split(/\s+/)[0];
168
+ let id;
169
+ if (first === "next") {
170
+ id = nextTheme(s.theme);
171
+ }
172
+ else if (isValidTheme(first)) {
173
+ id = first;
174
+ }
175
+ if (id && id !== s.theme) {
176
+ setGameState({ theme: id }, stateDir);
177
+ runtime.bumpGameState();
178
+ ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
179
+ }
180
+ }
181
+ /** TUI display-mode submenu — full vs minimal (current marked ✓). */
182
+ async function tuiSubmenu(ctx, runtime, stateDir) {
183
+ const s = getGameState(stateDir);
184
+ const mark = (m) => (s.tui_display_mode === m ? " ✓" : "");
185
+ const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
186
+ `full${mark("full")} — bars, stats, flair`,
187
+ `minimal${mark("minimal")} — one-line level + cache %`,
188
+ "Back",
189
+ ]);
190
+ if (choice === undefined || choice === "Back")
191
+ return;
192
+ const mode = choice.split(/\s+/)[0];
193
+ if (mode === "full" || mode === "minimal") {
194
+ setGameState({ tui_display_mode: mode }, stateDir);
195
+ runtime.bumpGameState();
196
+ ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
197
+ }
198
+ }
199
+ /** Achievements view — terse notify list + a read-only select viewer. */
200
+ async function achievementsView(ctx, stateDir) {
201
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
202
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
203
+ for (const r of rows)
204
+ ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
205
+ const lines = rows.length
206
+ ? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
207
+ : ["(none unlocked yet — keep compacting!)"];
208
+ // select() as a read-only viewer; any selection / cancel returns to the menu.
209
+ await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
210
+ }
108
211
  /** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
109
212
  export function registerGameCommands(pi, runtime) {
110
213
  const description = "Game mode toggle + theme picker + TUI display mode. Usage: /mega-compact-settings [on|off|theme [id|next]|tui [full|minimal]|achievements]";
@@ -16,13 +16,13 @@ import { THEME_IDS } from "../src/config/themes.js";
16
16
  // that wires the command against a fake pi without binding to the real pi
17
17
  // module at load time.
18
18
  const require = createRequire(import.meta.url);
19
- function makeHarness(stateDir) {
19
+ function makeHarness(stateDir, select) {
20
20
  const commands = {};
21
21
  const notifies = [];
22
22
  const runtime = { bindRepo: () => { }, currentStateDir: stateDir, bumpGameState: () => { } };
23
23
  const ctx = {
24
24
  cwd: stateDir,
25
- ui: { notify: (s) => notifies.push(s) },
25
+ ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
26
26
  };
27
27
  const fakePi = {
28
28
  registerCommand: (name, opts) => {
@@ -63,6 +63,35 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
63
63
  assert.ok(lines.some((l) => l.includes("transparent")));
64
64
  assert.ok(lines.some((l) => l.includes("tui:")));
65
65
  });
66
+ it("bare command opens interactive menu (select) and toggles game mode", async () => {
67
+ const seq = ["Turn game mode ON", "Done"];
68
+ let i = 0;
69
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
70
+ h.notifies.length = 0;
71
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
72
+ assert.equal(getGameState().game_mode_on, true);
73
+ assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
74
+ // toggle back off via the menu
75
+ const seq2 = ["Turn game mode OFF", "Done"];
76
+ let j = 0;
77
+ const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
78
+ await h2.commands["mega-compact-settings"].handler("", h2.ctx);
79
+ assert.equal(getGameState().game_mode_on, false);
80
+ });
81
+ it("bare command falls back to status print when select is unavailable", async () => {
82
+ // default harness has no select — mimics RPC/print mode
83
+ const lines = await run("");
84
+ assert.ok(lines.some((l) => l.includes("game mode: off")));
85
+ });
86
+ it("menu Theme… → picks a theme and persists", async () => {
87
+ const seq = ["Theme…", "retro Retro Terminal", "Done"];
88
+ let i = 0;
89
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
90
+ h.notifies.length = 0;
91
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
92
+ assert.equal(getGameState().theme, "retro");
93
+ assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
94
+ });
66
95
  it("on enables game mode and persists", async () => {
67
96
  await run("on");
68
97
  assert.equal(getGameState().game_mode_on, true);
@@ -470,6 +470,21 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
470
470
  // eslint-disable-next-line no-console
471
471
  console.log(`[mega-compact] dashboard server running: ${url}`);
472
472
 
473
+ // v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
474
+ // resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
475
+ // browser hit ::1:port and get connection refused. PREVENT-PI-004
476
+ // (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
477
+ // hosts or a ::1 already in use just skip the mirror.
478
+ let v6: ReturnType<typeof createServer> | undefined;
479
+ const v4Handler = server.listeners("request")[0];
480
+ if (v4Handler) {
481
+ v6 = createServer((r, s) => (v4Handler as (a: IncomingMessage, b: ServerResponse) => void).call(server, r, s));
482
+ v6.on("error", (e: NodeJS.ErrnoException) =>
483
+ log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }),
484
+ );
485
+ v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
486
+ }
487
+
473
488
  // Write port.pid
474
489
  try {
475
490
  writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
@@ -481,6 +496,7 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
481
496
  const cleanup = () => {
482
497
  try { unlinkSync(portFile); } catch { /* already gone */ }
483
498
  server.close();
499
+ try { v6?.close(); } catch { /* not bound */ }
484
500
  process.exit(0);
485
501
  };
486
502
  process.on("SIGTERM", cleanup);
@@ -26,13 +26,16 @@ type Harness = {
26
26
  ctx: any;
27
27
  };
28
28
 
29
- function makeHarness(stateDir: string): Harness {
29
+ function makeHarness(
30
+ stateDir: string,
31
+ select?: (title: string, options: string[]) => Promise<string | undefined>,
32
+ ): Harness {
30
33
  const commands: Record<string, Cmd> = {};
31
34
  const notifies: string[] = [];
32
35
  const runtime = { bindRepo: () => {}, currentStateDir: stateDir, bumpGameState: () => {} };
33
36
  const ctx = {
34
37
  cwd: stateDir,
35
- ui: { notify: (s: string) => notifies.push(s) },
38
+ ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
36
39
  };
37
40
  const fakePi = {
38
41
  registerCommand: (name: string, opts: Cmd) => {
@@ -80,6 +83,38 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
80
83
  assert.ok(lines.some((l) => l.includes("tui:")));
81
84
  });
82
85
 
86
+ it("bare command opens interactive menu (select) and toggles game mode", async () => {
87
+ const seq = ["Turn game mode ON", "Done"];
88
+ let i = 0;
89
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
90
+ h.notifies.length = 0;
91
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
92
+ assert.equal(getGameState().game_mode_on, true);
93
+ assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
94
+ // toggle back off via the menu
95
+ const seq2 = ["Turn game mode OFF", "Done"];
96
+ let j = 0;
97
+ const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
98
+ await h2.commands["mega-compact-settings"].handler("", h2.ctx);
99
+ assert.equal(getGameState().game_mode_on, false);
100
+ });
101
+
102
+ it("bare command falls back to status print when select is unavailable", async () => {
103
+ // default harness has no select — mimics RPC/print mode
104
+ const lines = await run("");
105
+ assert.ok(lines.some((l) => l.includes("game mode: off")));
106
+ });
107
+
108
+ it("menu Theme… → picks a theme and persists", async () => {
109
+ const seq = ["Theme…", "retro Retro Terminal", "Done"];
110
+ let i = 0;
111
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
112
+ h.notifies.length = 0;
113
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
114
+ assert.equal(getGameState().theme, "retro");
115
+ assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
116
+ });
117
+
83
118
  it("on enables game mode and persists", async () => {
84
119
  await run("on");
85
120
  assert.equal(getGameState().game_mode_on, true);
@@ -55,8 +55,19 @@ async function handleSettings(
55
55
  const stateDir = runtime.currentStateDir;
56
56
  const parts = args.trim().split(/\s+/).filter(Boolean);
57
57
 
58
- // bare → print current state.
58
+ // bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
59
+ // static status print when there's no interactive UI (RPC/print mode, or a
60
+ // test harness stubbing only notify). CLI subcommands below still work for
61
+ // power users + scripts.
59
62
  if (parts.length === 0) {
63
+ if (typeof ctx.ui.select === "function") {
64
+ try {
65
+ await runInteractiveMenu(ctx, runtime, stateDir);
66
+ return;
67
+ } catch {
68
+ // select threw (non-interactive impl) → fall through to status print
69
+ }
70
+ }
60
71
  const s = getGameState(stateDir);
61
72
  for (const line of fmtState(s)) ctx.ui.notify(line);
62
73
  return;
@@ -126,6 +137,111 @@ async function handleSettings(
126
137
  );
127
138
  }
128
139
 
140
+ /** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
141
+ * (a real TUI picker in interactive mode). Loops until the user cancels (or
142
+ * picks "Done"). Each action mutates the global game_state row + bumps the
143
+ * runtime cache so the widget/dashboard reflect it immediately.
144
+ *
145
+ * Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
146
+ * unavailable we fall back to the static fmtState notify print. */
147
+ async function runInteractiveMenu(
148
+ ctx: ExtensionContext,
149
+ runtime: MegaRuntime,
150
+ stateDir: string,
151
+ ): Promise<void> {
152
+ for (;;) {
153
+ const s = getGameState(stateDir);
154
+ const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
155
+ const choice = await ctx.ui.select(
156
+ `[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`,
157
+ [toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"],
158
+ );
159
+ if (choice === undefined || choice === "Done") return;
160
+ if (choice === toggleLabel) {
161
+ const next = !s.game_mode_on;
162
+ setGameState({ game_mode_on: next }, stateDir);
163
+ runtime.bumpGameState();
164
+ ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
165
+ continue;
166
+ }
167
+ if (choice === "Theme…") {
168
+ await themeSubmenu(ctx, runtime, stateDir);
169
+ continue;
170
+ }
171
+ if (choice === "TUI display mode…") {
172
+ await tuiSubmenu(ctx, runtime, stateDir);
173
+ continue;
174
+ }
175
+ if (choice === "Achievements…") {
176
+ await achievementsView(ctx, stateDir);
177
+ continue;
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
183
+ async function themeSubmenu(
184
+ ctx: ExtensionContext,
185
+ runtime: MegaRuntime,
186
+ stateDir: string,
187
+ ): Promise<void> {
188
+ const s = getGameState(stateDir);
189
+ const opts = THEMES.map((t) => {
190
+ const mark = t.id === s.theme ? " ✓" : "";
191
+ return `${t.id}${mark} ${t.label}`;
192
+ });
193
+ opts.push("next (cycle to next theme)");
194
+ opts.push("Back");
195
+ const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
196
+ if (choice === undefined || choice === "Back") return;
197
+ const first = choice.split(/\s+/)[0]!;
198
+ let id: string | undefined;
199
+ if (first === "next") {
200
+ id = nextTheme(s.theme);
201
+ } else if (isValidTheme(first)) {
202
+ id = first;
203
+ }
204
+ if (id && id !== s.theme) {
205
+ setGameState({ theme: id }, stateDir);
206
+ runtime.bumpGameState();
207
+ ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
208
+ }
209
+ }
210
+
211
+ /** TUI display-mode submenu — full vs minimal (current marked ✓). */
212
+ async function tuiSubmenu(
213
+ ctx: ExtensionContext,
214
+ runtime: MegaRuntime,
215
+ stateDir: string,
216
+ ): Promise<void> {
217
+ const s = getGameState(stateDir);
218
+ const mark = (m: string) => (s.tui_display_mode === m ? " ✓" : "");
219
+ const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
220
+ `full${mark("full")} — bars, stats, flair`,
221
+ `minimal${mark("minimal")} — one-line level + cache %`,
222
+ "Back",
223
+ ]);
224
+ if (choice === undefined || choice === "Back") return;
225
+ const mode = choice.split(/\s+/)[0];
226
+ if (mode === "full" || mode === "minimal") {
227
+ setGameState({ tui_display_mode: mode }, stateDir);
228
+ runtime.bumpGameState();
229
+ ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
230
+ }
231
+ }
232
+
233
+ /** Achievements view — terse notify list + a read-only select viewer. */
234
+ async function achievementsView(ctx: ExtensionContext, stateDir: string): Promise<void> {
235
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
236
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
237
+ for (const r of rows) ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
238
+ const lines = rows.length
239
+ ? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
240
+ : ["(none unlocked yet — keep compacting!)"];
241
+ // select() as a read-only viewer; any selection / cancel returns to the menu.
242
+ await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
243
+ }
244
+
129
245
  /** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
130
246
  export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
131
247
  const description =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",