pi-mega-compact 0.8.1 → 0.8.3

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);
@@ -27,6 +27,15 @@ import { listAchievements } from "../src/store/sqlite/game-achievements.js";
27
27
  import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } from "../src/config/themes.js";
28
28
  /** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
29
29
  const TAG = "mega-compact-settings";
30
+ /** Apply a game_state mutation to the live TUI: evict the memoized cache
31
+ * (bumpGameState) THEN recompute the widget snapshot (snapshot(ctx)) so the
32
+ * panel picks up the new theme/mode/toggle immediately — no pi restart needed.
33
+ * snapshot() re-reads the (evicted) game_state into widgetData and re-registers
34
+ * the widget factory. Order matters: bump first, snapshot second. */
35
+ function applyChange(ctx, runtime) {
36
+ runtime.bumpGameState();
37
+ runtime.snapshot(ctx);
38
+ }
30
39
  /** Format the current state as a human-readable status line set. */
31
40
  function fmtState(s) {
32
41
  return [
@@ -40,8 +49,20 @@ async function handleSettings(args, ctx, runtime) {
40
49
  runtime.bindRepo(ctx.cwd);
41
50
  const stateDir = runtime.currentStateDir;
42
51
  const parts = args.trim().split(/\s+/).filter(Boolean);
43
- // bare → print current state.
52
+ // bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
53
+ // static status print when there's no interactive UI (RPC/print mode, or a
54
+ // test harness stubbing only notify). CLI subcommands below still work for
55
+ // power users + scripts.
44
56
  if (parts.length === 0) {
57
+ if (typeof ctx.ui.select === "function") {
58
+ try {
59
+ await runInteractiveMenu(ctx, runtime, stateDir);
60
+ return;
61
+ }
62
+ catch {
63
+ // select threw (non-interactive impl) → fall through to status print
64
+ }
65
+ }
45
66
  const s = getGameState(stateDir);
46
67
  for (const line of fmtState(s))
47
68
  ctx.ui.notify(line);
@@ -60,7 +81,7 @@ async function handleSettings(args, ctx, runtime) {
60
81
  // on|off
61
82
  if (sub === "on" || sub === "off") {
62
83
  const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
63
- runtime.bumpGameState();
84
+ applyChange(ctx, runtime);
64
85
  ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
65
86
  return;
66
87
  }
@@ -87,7 +108,7 @@ async function handleSettings(args, ctx, runtime) {
87
108
  return;
88
109
  }
89
110
  const s = setGameState({ theme: id }, stateDir);
90
- runtime.bumpGameState();
111
+ applyChange(ctx, runtime);
91
112
  ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
92
113
  return;
93
114
  }
@@ -99,12 +120,103 @@ async function handleSettings(args, ctx, runtime) {
99
120
  return;
100
121
  }
101
122
  const s = setGameState({ tui_display_mode: arg }, stateDir);
102
- runtime.bumpGameState();
123
+ applyChange(ctx, runtime);
103
124
  ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
104
125
  return;
105
126
  }
106
127
  ctx.ui.notify(`[${TAG}] usage: /${TAG} [on|off|theme [id|next]|tui [full|minimal]|achievements]`);
107
128
  }
129
+ /** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
130
+ * (a real TUI picker in interactive mode). Loops until the user cancels (or
131
+ * picks "Done"). Each action mutates the global game_state row + bumps the
132
+ * runtime cache so the widget/dashboard reflect it immediately.
133
+ *
134
+ * Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
135
+ * unavailable we fall back to the static fmtState notify print. */
136
+ async function runInteractiveMenu(ctx, runtime, stateDir) {
137
+ for (;;) {
138
+ const s = getGameState(stateDir);
139
+ const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
140
+ 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"]);
141
+ if (choice === undefined || choice === "Done")
142
+ return;
143
+ if (choice === toggleLabel) {
144
+ const next = !s.game_mode_on;
145
+ setGameState({ game_mode_on: next }, stateDir);
146
+ applyChange(ctx, runtime);
147
+ ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
148
+ continue;
149
+ }
150
+ if (choice === "Theme…") {
151
+ await themeSubmenu(ctx, runtime, stateDir);
152
+ continue;
153
+ }
154
+ if (choice === "TUI display mode…") {
155
+ await tuiSubmenu(ctx, runtime, stateDir);
156
+ continue;
157
+ }
158
+ if (choice === "Achievements…") {
159
+ await achievementsView(ctx, stateDir);
160
+ continue;
161
+ }
162
+ }
163
+ }
164
+ /** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
165
+ async function themeSubmenu(ctx, runtime, stateDir) {
166
+ const s = getGameState(stateDir);
167
+ const opts = THEMES.map((t) => {
168
+ const mark = t.id === s.theme ? " ✓" : "";
169
+ return `${t.id}${mark} ${t.label}`;
170
+ });
171
+ opts.push("next (cycle to next theme)");
172
+ opts.push("Back");
173
+ const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
174
+ if (choice === undefined || choice === "Back")
175
+ return;
176
+ const first = choice.split(/\s+/)[0];
177
+ let id;
178
+ if (first === "next") {
179
+ id = nextTheme(s.theme);
180
+ }
181
+ else if (isValidTheme(first)) {
182
+ id = first;
183
+ }
184
+ if (id && id !== s.theme) {
185
+ setGameState({ theme: id }, stateDir);
186
+ applyChange(ctx, runtime);
187
+ ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
188
+ }
189
+ }
190
+ /** TUI display-mode submenu — full vs minimal (current marked ✓). */
191
+ async function tuiSubmenu(ctx, runtime, stateDir) {
192
+ const s = getGameState(stateDir);
193
+ const mark = (m) => (s.tui_display_mode === m ? " ✓" : "");
194
+ const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
195
+ `full${mark("full")} — bars, stats, flair`,
196
+ `minimal${mark("minimal")} — one-line level + cache %`,
197
+ "Back",
198
+ ]);
199
+ if (choice === undefined || choice === "Back")
200
+ return;
201
+ const mode = choice.split(/\s+/)[0];
202
+ if (mode === "full" || mode === "minimal") {
203
+ setGameState({ tui_display_mode: mode }, stateDir);
204
+ applyChange(ctx, runtime);
205
+ ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
206
+ }
207
+ }
208
+ /** Achievements view — terse notify list + a read-only select viewer. */
209
+ async function achievementsView(ctx, stateDir) {
210
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
211
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
212
+ for (const r of rows)
213
+ ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
214
+ const lines = rows.length
215
+ ? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
216
+ : ["(none unlocked yet — keep compacting!)"];
217
+ // select() as a read-only viewer; any selection / cancel returns to the menu.
218
+ await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
219
+ }
108
220
  /** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
109
221
  export function registerGameCommands(pi, runtime) {
110
222
  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,19 @@ 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, snapshotSpy) {
20
20
  const commands = {};
21
21
  const notifies = [];
22
- const runtime = { bindRepo: () => { }, currentStateDir: stateDir, bumpGameState: () => { } };
22
+ const snapshotCalls = [];
23
+ const runtime = {
24
+ bindRepo: () => { },
25
+ currentStateDir: stateDir,
26
+ bumpGameState: () => { },
27
+ snapshot: () => { snapshotCalls.push(1); snapshotSpy?.(); },
28
+ };
23
29
  const ctx = {
24
30
  cwd: stateDir,
25
- ui: { notify: (s) => notifies.push(s) },
31
+ ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
26
32
  };
27
33
  const fakePi = {
28
34
  registerCommand: (name, opts) => {
@@ -33,7 +39,7 @@ function makeHarness(stateDir) {
33
39
  // test from binding to the real pi module at load time.
34
40
  const mod = require("./mega-game-cmds.js");
35
41
  mod.registerGameCommands(fakePi, runtime);
36
- return { commands, notifies, ctx };
42
+ return { commands, notifies, ctx, snapshotCalls };
37
43
  }
38
44
  describe("/mega-compact-settings (S30; /mega-game alias)", () => {
39
45
  let dir;
@@ -63,6 +69,45 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
63
69
  assert.ok(lines.some((l) => l.includes("transparent")));
64
70
  assert.ok(lines.some((l) => l.includes("tui:")));
65
71
  });
72
+ it("bare command opens interactive menu (select) and toggles game mode", async () => {
73
+ const seq = ["Turn game mode ON", "Done"];
74
+ let i = 0;
75
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
76
+ h.notifies.length = 0;
77
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
78
+ assert.equal(getGameState().game_mode_on, true);
79
+ assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
80
+ // toggle back off via the menu
81
+ const seq2 = ["Turn game mode OFF", "Done"];
82
+ let j = 0;
83
+ const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
84
+ await h2.commands["mega-compact-settings"].handler("", h2.ctx);
85
+ assert.equal(getGameState().game_mode_on, false);
86
+ });
87
+ it("bare command falls back to status print when select is unavailable", async () => {
88
+ // default harness has no select — mimics RPC/print mode
89
+ const lines = await run("");
90
+ assert.ok(lines.some((l) => l.includes("game mode: off")));
91
+ });
92
+ it("menu Theme… → picks a theme and persists", async () => {
93
+ const seq = ["Theme…", "retro Retro Terminal", "Done"];
94
+ let i = 0;
95
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
96
+ h.notifies.length = 0;
97
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
98
+ assert.equal(getGameState().theme, "retro");
99
+ assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
100
+ });
101
+ it("menu toggle calls runtime.snapshot() so the widget refreshes live", async () => {
102
+ const seq = ["Turn game mode ON", "Done"];
103
+ let i = 0;
104
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
105
+ h.notifies.length = 0;
106
+ h.snapshotCalls.length = 0;
107
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
108
+ assert.equal(getGameState().game_mode_on, true);
109
+ assert.ok(h.snapshotCalls.length >= 1, "snapshot() called so widget refreshes live");
110
+ });
66
111
  it("on enables game mode and persists", async () => {
67
112
  await run("on");
68
113
  assert.equal(getGameState().game_mode_on, true);
@@ -44,6 +44,7 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
44
44
  }
45
45
  function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
46
46
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
47
+ runtime.setEffect?.("pulse", "accent", 1500); // v0.8.3: ambient border pulse during compaction
47
48
  // S21.2: reset the per-compaction memory-op counter so the post-compact
48
49
  // consolidate pass only fires when memory rows actually changed during the
49
50
  // compaction window (turn_end → auto-review may have written some).
@@ -60,6 +60,12 @@ export class MegaRuntime {
60
60
  // when cachePct > 100). Copied into widgetData.megaCacheFlare on the next
61
61
  // snapshot() so the widget renders the oopsie gag, then reset (one cycle).
62
62
  megaCacheFlare = false;
63
+ /** v0.8.3: ambient effect state for animated panel borders keyed off
64
+ * status transitions (level-up, mega-cache overshoot, achievement unlock,
65
+ * compaction start). Threaded into widgetData as `activeEffect`; the widget
66
+ * computes the per-frame phase from startedAt vs Date.now() (non-expired).
67
+ * Null when idle/expired. */
68
+ activeEffect = null;
63
69
  megaCacheFlarePct = 0;
64
70
  levelUpFlare = false;
65
71
  lastLevel = 0;
@@ -510,8 +516,11 @@ export class MegaRuntime {
510
516
  const gs = this.getCachedGameState();
511
517
  // S34: derive the level-up flare from the turn count each snapshot.
512
518
  const curLevel = this.getTurnLevel();
513
- if (curLevel > this.lastLevel)
519
+ if (curLevel > this.lastLevel) {
514
520
  this.levelUpFlare = true;
521
+ // v0.8.3: arm a pulse border effect to celebrate the level-up.
522
+ this.setEffect("pulse", "accent", 1500);
523
+ }
515
524
  const cachePct = st.dedupHitRate * 100;
516
525
  this.widgetData = {
517
526
  version: ownVersion(),
@@ -555,6 +564,9 @@ export class MegaRuntime {
555
564
  levelUpFlare: this.levelUpFlare,
556
565
  achievementFlare: this.achievementFlare,
557
566
  achievementFlareTitles: this.achievementFlareTitles,
567
+ // v0.8.3: ambient border effect — threaded live so the widget can
568
+ // compute the per-frame phase and render animated borders.
569
+ activeEffect: this.activeEffect,
558
570
  };
559
571
  // S33: consume the flare after copying it into widgetData so it fires
560
572
  // for exactly one render cycle (the gag flares once, then clears).
@@ -568,6 +580,17 @@ export class MegaRuntime {
568
580
  // (mirrors the megaCacheFlare/levelUpFlare one-shot semantics).
569
581
  this.achievementFlare = false;
570
582
  this.achievementFlareTitles = [];
583
+ // v0.8.3: expire the ambient border effect once its time window has
584
+ // elapsed. SEPARATE from the one-shot flares above (those are per-cycle
585
+ // consumes; activeEffect is time-windowed and cleared when Date.now()
586
+ // crosses startedAt + durationMs). The widget also defends this per-frame
587
+ // (effectBorderSgr returns '' once expired), so this is bookkeeping to
588
+ // free the slot and prevent a stale effect lingering between snapshots.
589
+ if (this.activeEffect &&
590
+ Date.now() - this.activeEffect.startedAt >=
591
+ this.activeEffect.durationMs) {
592
+ this.activeEffect = null;
593
+ }
571
594
  // Auto-fit: register a factory so pi re-renders the panel at the REAL
572
595
  // terminal width every frame (tui.columns), instead of guessing with
573
596
  // process.stdout.columns. buildWidgetLines reads this.widgetData live.
@@ -833,17 +856,31 @@ export class MegaRuntime {
833
856
  return turnLevel(this.currentTurn);
834
857
  }
835
858
  /** S33: arm the transient MEGA CACHE flare so the next snapshot() copies it
836
- * into widgetData and the widget renders the oopsie gag for one cycle. */
859
+ * into widgetData and the widget renders the oopsie gag for one cycle.
860
+ * v0.8.3: also arm a 'flash' ambient effect on the panel borders (mega
861
+ * color) for 1.2s. */
837
862
  armMegaCacheFlare(peakPct) {
838
863
  this.megaCacheFlare = true;
839
864
  this.megaCacheFlarePct = peakPct;
865
+ this.setEffect("flash", "mega", 1200);
840
866
  }
841
867
  /** S35: arm the transient achievement-unlock flare with the newly-unlocked
842
868
  * titles so the next snapshot() copies them into widgetData and the widget
843
- * renders the one-time unlock toast for one render cycle. */
869
+ * renders the one-time unlock toast for one render cycle.
870
+ * v0.8.3: also arm a 'pulse' ambient effect on the panel borders (accent
871
+ * color) for 2s to celebrate the unlock. */
844
872
  armAchievementFlare(titles) {
845
873
  this.achievementFlare = true;
846
874
  this.achievementFlareTitles = titles;
875
+ this.setEffect("pulse", "accent", 2000);
876
+ }
877
+ /** v0.8.3: arm an ambient border effect (animated pulse/flash on the panel
878
+ * borders). Replaces any in-flight effect (last call wins — a later event
879
+ * like a level-up during an achievement pulse simply overrides). The widget
880
+ * reads activeEffect each frame and computes the per-frame phase from
881
+ * startedAt vs Date.now(); it renders '' once the window elapses. */
882
+ setEffect(type, role, durationMs) {
883
+ this.activeEffect = { type, role, startedAt: Date.now(), durationMs };
847
884
  }
848
885
  /** Build the sync onTier callback that paints the live per-tier trace. */
849
886
  makeTierCallback(ctx) {
@@ -52,6 +52,64 @@ function minimalConfig(stateDir) {
52
52
  function freshDir() {
53
53
  return mkdtempSync(join(tmpdir(), "mc-state-"));
54
54
  }
55
+ describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
56
+ const dirs = [];
57
+ after(() => {
58
+ for (const d of dirs) {
59
+ try {
60
+ closeStore(d);
61
+ }
62
+ catch { /* */ }
63
+ }
64
+ delete process.env.MEGACOMPACT_STATE_DIR;
65
+ for (const d of dirs)
66
+ rmSync(d, { recursive: true, force: true });
67
+ });
68
+ it("activeEffect starts null and setEffect arms it with startedAt ~now", () => {
69
+ const dir = freshDir();
70
+ dirs.push(dir);
71
+ process.env.MEGACOMPACT_STATE_DIR = dir;
72
+ const rt = new MegaRuntime(minimalConfig(dir));
73
+ assert.equal(rt.activeEffect, null, "idle on construction");
74
+ const before = Date.now();
75
+ rt.setEffect("pulse", "accent", 2000);
76
+ const after = Date.now();
77
+ assert.equal(rt.activeEffect.type, "pulse");
78
+ assert.equal(rt.activeEffect.role, "accent");
79
+ assert.equal(rt.activeEffect.durationMs, 2000);
80
+ assert.ok(rt.activeEffect.startedAt >= before && rt.activeEffect.startedAt <= after, "startedAt within the call window");
81
+ });
82
+ it("setEffect replaces an in-flight effect (last call wins)", () => {
83
+ const dir = freshDir();
84
+ dirs.push(dir);
85
+ process.env.MEGACOMPACT_STATE_DIR = dir;
86
+ const rt = new MegaRuntime(minimalConfig(dir));
87
+ rt.setEffect("pulse", "accent", 2000);
88
+ const first = rt.activeEffect;
89
+ rt.setEffect("flash", "mega", 1200);
90
+ assert.notEqual(rt.activeEffect, first, "a fresh object replaced the prior");
91
+ assert.equal(rt.activeEffect.type, "flash");
92
+ assert.equal(rt.activeEffect.role, "mega");
93
+ assert.equal(rt.activeEffect.durationMs, 1200);
94
+ });
95
+ it("a backdated effect is recognized as expired by the snapshot predicate", () => {
96
+ const dir = freshDir();
97
+ dirs.push(dir);
98
+ process.env.MEGACOMPACT_STATE_DIR = dir;
99
+ const rt = new MegaRuntime(minimalConfig(dir));
100
+ rt.setEffect("pulse", "accent", 2000);
101
+ // Backdate startedAt past the duration window (no snapshot/ctx needed —
102
+ // this mirrors the exact predicate snapshot() runs after the flare consume).
103
+ rt.activeEffect.startedAt = Date.now() - 3000;
104
+ const expired = !!rt.activeEffect &&
105
+ Date.now() - rt.activeEffect.startedAt >= rt.activeEffect.durationMs;
106
+ assert.ok(expired, "backdated effect satisfies the expiry predicate");
107
+ // Clearing mirrors the snapshot() bookkeeping branch:
108
+ if (expired)
109
+ rt.activeEffect = null;
110
+ assert.equal(rt.activeEffect, null, "cleared once expired");
111
+ });
112
+ });
55
113
  describe("MegaRuntime game-state cache (S31)", () => {
56
114
  const dirs = [];
57
115
  after(() => {
@@ -121,6 +121,47 @@ function panelBar(width, ch = "─", panelBg = DEFAULT_PANEL_BG) {
121
121
  // ever swapped for a wide/fullwidth character.
122
122
  return truncateToWidth(panelBg + ch.repeat(Math.max(0, width)), width, "", false) + "\x1b[0m";
123
123
  }
124
+ // ── v0.8.3: ambient border-effect helpers ───────────────────────────────
125
+ // The panel borders animate when an `activeEffect` is armed (level-up,
126
+ // mega-cache overshoot, achievement unlock, compaction start). Two modes:
127
+ // • pulse — a sine ramp on a 256-color base (accent=51 / mega=214 / red=203):
128
+ // the base index is scaled by sin(π·t) so the border swells 0→peak→0 over
129
+ // the duration, then returns to '' (idle). 256-color indices are clamped
130
+ // to 0–255 defensively (the bases are all ≤214 so the clamp rarely bites).
131
+ // • flash — a 120ms hard on/off alternate using the base index at full.
132
+ // Returns '' when idle, expired, or elapsed<0 (clock skew) so non-effect
133
+ // renders are byte-identical to the pre-effect panel (S31 matrix stays green).
134
+ const EFFECT_BASE = {
135
+ accent: 51,
136
+ mega: 214,
137
+ red: 203,
138
+ };
139
+ /** Resolve the per-frame border-fg SGR for an active effect. '' when idle or
140
+ * expired (the widget's real per-frame expiry enforcer — snapshot-level clear
141
+ * is just bookkeeping since snapshot is event-driven). */
142
+ function effectBorderSgr(ae, now) {
143
+ if (!ae)
144
+ return "";
145
+ const elapsed = now - ae.startedAt;
146
+ if (elapsed < 0 || elapsed >= ae.durationMs)
147
+ return "";
148
+ const base = EFFECT_BASE[ae.role];
149
+ if (ae.type === "flash") {
150
+ // 120ms hard alternate: on (full base) / off (no SGR).
151
+ return Math.floor(elapsed / 120) % 2 === 0 ? `\x1b[38;5;${base}m` : "";
152
+ }
153
+ // pulse: sine ramp 0 → peak → 0 over the duration.
154
+ const t = elapsed / ae.durationMs;
155
+ const amp = Math.sin(Math.PI * t); // 0 at start/end, 1 at midpoint
156
+ const idx = Math.max(0, Math.min(255, Math.round(base * amp)));
157
+ return `\x1b[38;5;${idx}m`;
158
+ }
159
+ /** Prepend the effect border SGR to a panel bar line. The SGR is a pure-fg
160
+ * escape (zero visible width), so it never perturbs truncateToWidth's width
161
+ * math — the bar's own `\x1b[0m` tail resets both fg + bg. No-op when sgr=''. */
162
+ function effectBar(bar, sgr) {
163
+ return sgr ? sgr + bar : bar;
164
+ }
124
165
  /** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
125
166
  * 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
126
167
  function fmtTokens(x) {
@@ -175,11 +216,16 @@ export function buildWidgetLines(wd, width, activeAgents) {
175
216
  // every panelLine/panelBar/wrapLine so the bg stays continuous and the width
176
217
  // guard (truncateToWidth) still holds for transparent themes.
177
218
  const panelBg = wd?.theme ? panelBgFor(wd.theme) : DEFAULT_PANEL_BG;
219
+ // v0.8.3: resolve the animated border SGR once per render. '' when idle,
220
+ // expired, or wd is null (warm-up) — so non-effect renders are byte-identical
221
+ // to the pre-effect panel (the existing S31 matrix tests stay green).
222
+ const now = Date.now();
223
+ const borderSgr = effectBorderSgr(wd?.activeEffect ?? null, now);
178
224
  if (!wd) {
179
225
  return [
180
- panelBar(width, "─", panelBg),
226
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
181
227
  panelLine(" mega-compact: warming up…", width, panelBg),
182
- panelBar(width, "─", panelBg),
228
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
183
229
  ];
184
230
  }
185
231
  // S31: minimal TUI mode — a single content line `LVL n | cache NN%` flanked
@@ -199,9 +245,9 @@ export function buildWidgetLines(wd, width, activeAgents) {
199
245
  ? `${accent}${wd.gameMode && wd.levelUpFlare ? "\x1b[5m" : ""}LVL ${lvl}${wd.gameMode && wd.levelUpFlare ? "\x1b[0m" : ""}${sgrReset(accent)} ${C.dim}|${C.reset} cache ${cacheStr}${megaFlare}`
200
246
  : `cache ${cacheStr}${megaFlare}`;
201
247
  return [
202
- panelBar(width, "─", panelBg),
248
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
203
249
  panelLine(` ${body}`, width, panelBg),
204
- panelBar(width, "─", panelBg),
250
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
205
251
  ];
206
252
  }
207
253
  const pulse = wd.pulsing
@@ -234,7 +280,7 @@ export function buildWidgetLines(wd, width, activeAgents) {
234
280
  // Wrap to terminal width and pad each line
235
281
  const wrapped = wrapLine(content, width - 2, panelBg); // 2-char indent
236
282
  const lines = [
237
- panelBar(width, "─", panelBg),
283
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
238
284
  ...wrapped.map((l) => panelLine(l, width, panelBg)),
239
285
  ];
240
286
  // L4 — agents block (S27, count + status; per-agent tokens gated on P0)
@@ -265,6 +311,6 @@ export function buildWidgetLines(wd, width, activeAgents) {
265
311
  lines.push(panelLine(` ${accentSgr}🏆 Achievement unlocked: ${titlesStr}${sgrReset(accentSgr)}`, width, panelBg));
266
312
  }
267
313
  // bottom border
268
- lines.push(panelBar(width, "─", panelBg));
314
+ lines.push(effectBar(panelBar(width, "─", panelBg), borderSgr));
269
315
  return lines;
270
316
  }
@@ -136,6 +136,51 @@ describe("buildWidgetLines (S31)", () => {
136
136
  }
137
137
  }
138
138
  });
139
+ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
140
+ const effBase = (overrides = {}) => baseWd({
141
+ theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
142
+ });
143
+ const isBorder = (l) => l.includes("─");
144
+ it("activeEffect (pulse, mid-window) -> border lines carry a 256-color fg SGR", () => {
145
+ const ae = { type: "pulse", role: "accent", startedAt: Date.now() - 250, durationMs: 2000 };
146
+ const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
147
+ const borders = lines.filter(isBorder);
148
+ assert.ok(borders.length >= 2, "has top + bottom borders");
149
+ for (const b of borders) {
150
+ assert.ok(b.includes("\x1b[38;5;"), `border carries 256-color fg: ${JSON.stringify(b)}`);
151
+ }
152
+ });
153
+ it("activeEffect null -> plain borders, no 38;5 fg SGR on border lines", () => {
154
+ const lines = buildWidgetLines(effBase({ activeEffect: null }), WIDTH, 0);
155
+ const borders = lines.filter(isBorder);
156
+ for (const b of borders) {
157
+ assert.ok(!b.includes("\x1b[38;5;"), `no effect SGR on plain border: ${JSON.stringify(b)}`);
158
+ }
159
+ });
160
+ it("expired activeEffect -> plain borders (per-frame expiry enforced)", () => {
161
+ const ae = { type: "pulse", role: "accent", startedAt: Date.now() - 5000, durationMs: 1000 };
162
+ const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
163
+ const borders = lines.filter(isBorder);
164
+ for (const b of borders) {
165
+ assert.ok(!b.includes("\x1b[38;5;"), `expired effect -> plain border: ${JSON.stringify(b)}`);
166
+ }
167
+ });
168
+ it("activeEffect border lines are width-safe (pulse, minimal + full)", () => {
169
+ for (const tuiMode of ["minimal", "full"]) {
170
+ const ae = { type: "pulse", role: "mega", startedAt: Date.now() - 100, durationMs: 2000 };
171
+ const lines = buildWidgetLines(effBase({ activeEffect: ae, tuiMode }), 60, 0);
172
+ for (const l of lines)
173
+ assert.ok(visibleWidth(l) <= 60, `width safe (${tuiMode}): ${visibleWidth(l)}`);
174
+ }
175
+ });
176
+ it("flash effect mid-window border carries the full base index SGR", () => {
177
+ // Force an 'on' phase of the 120ms alternate by starting just now.
178
+ const ae = { type: "flash", role: "red", startedAt: Date.now(), durationMs: 1200 };
179
+ const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
180
+ const borders = lines.filter(isBorder);
181
+ assert.ok(borders.some((b) => b.includes("\x1b[38;5;203m")), `flash-on phase uses red base 203`);
182
+ });
183
+ });
139
184
  describe("buildWidgetLines achievement flare (S35)", () => {
140
185
  const achBase = (overrides = {}) => baseWd({
141
186
  theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
@@ -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);
@@ -24,15 +24,26 @@ type Harness = {
24
24
  commands: Record<string, Cmd>;
25
25
  notifies: string[];
26
26
  ctx: any;
27
+ snapshotCalls: number[];
27
28
  };
28
29
 
29
- function makeHarness(stateDir: string): Harness {
30
+ function makeHarness(
31
+ stateDir: string,
32
+ select?: (title: string, options: string[]) => Promise<string | undefined>,
33
+ snapshotSpy?: () => void,
34
+ ): Harness {
30
35
  const commands: Record<string, Cmd> = {};
31
36
  const notifies: string[] = [];
32
- const runtime = { bindRepo: () => {}, currentStateDir: stateDir, bumpGameState: () => {} };
37
+ const snapshotCalls: number[] = [];
38
+ const runtime = {
39
+ bindRepo: () => {},
40
+ currentStateDir: stateDir,
41
+ bumpGameState: () => {},
42
+ snapshot: () => { snapshotCalls.push(1); snapshotSpy?.(); },
43
+ };
33
44
  const ctx = {
34
45
  cwd: stateDir,
35
- ui: { notify: (s: string) => notifies.push(s) },
46
+ ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
36
47
  };
37
48
  const fakePi = {
38
49
  registerCommand: (name: string, opts: Cmd) => {
@@ -45,7 +56,7 @@ function makeHarness(stateDir: string): Harness {
45
56
  registerGameCommands: (pi: unknown, runtime: unknown) => void;
46
57
  };
47
58
  mod.registerGameCommands(fakePi, runtime);
48
- return { commands, notifies, ctx };
59
+ return { commands, notifies, ctx, snapshotCalls };
49
60
  }
50
61
 
51
62
  describe("/mega-compact-settings (S30; /mega-game alias)", () => {
@@ -80,6 +91,49 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
80
91
  assert.ok(lines.some((l) => l.includes("tui:")));
81
92
  });
82
93
 
94
+ it("bare command opens interactive menu (select) and toggles game mode", async () => {
95
+ const seq = ["Turn game mode ON", "Done"];
96
+ let i = 0;
97
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
98
+ h.notifies.length = 0;
99
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
100
+ assert.equal(getGameState().game_mode_on, true);
101
+ assert.ok(h.notifies.some((l) => l.includes("game mode ON")));
102
+ // toggle back off via the menu
103
+ const seq2 = ["Turn game mode OFF", "Done"];
104
+ let j = 0;
105
+ const h2 = makeHarness(dir, () => Promise.resolve(seq2[j++] ?? undefined));
106
+ await h2.commands["mega-compact-settings"].handler("", h2.ctx);
107
+ assert.equal(getGameState().game_mode_on, false);
108
+ });
109
+
110
+ it("bare command falls back to status print when select is unavailable", async () => {
111
+ // default harness has no select — mimics RPC/print mode
112
+ const lines = await run("");
113
+ assert.ok(lines.some((l) => l.includes("game mode: off")));
114
+ });
115
+
116
+ it("menu Theme… → picks a theme and persists", async () => {
117
+ const seq = ["Theme…", "retro Retro Terminal", "Done"];
118
+ let i = 0;
119
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
120
+ h.notifies.length = 0;
121
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
122
+ assert.equal(getGameState().theme, "retro");
123
+ assert.ok(h.notifies.some((l) => l.includes("theme → retro")));
124
+ });
125
+
126
+ it("menu toggle calls runtime.snapshot() so the widget refreshes live", async () => {
127
+ const seq = ["Turn game mode ON", "Done"];
128
+ let i = 0;
129
+ const h = makeHarness(dir, () => Promise.resolve(seq[i++] ?? undefined));
130
+ h.notifies.length = 0;
131
+ h.snapshotCalls.length = 0;
132
+ await h.commands["mega-compact-settings"].handler("", h.ctx);
133
+ assert.equal(getGameState().game_mode_on, true);
134
+ assert.ok(h.snapshotCalls.length >= 1, "snapshot() called so widget refreshes live");
135
+ });
136
+
83
137
  it("on enables game mode and persists", async () => {
84
138
  await run("on");
85
139
  assert.equal(getGameState().game_mode_on, true);
@@ -36,6 +36,16 @@ import { THEMES, THEME_IDS, getTheme, isValidTheme, nextTheme, DEFAULT_THEME } f
36
36
  /** Notify/usage tag + command name. Primary surface is /mega-compact-settings. */
37
37
  const TAG = "mega-compact-settings";
38
38
 
39
+ /** Apply a game_state mutation to the live TUI: evict the memoized cache
40
+ * (bumpGameState) THEN recompute the widget snapshot (snapshot(ctx)) so the
41
+ * panel picks up the new theme/mode/toggle immediately — no pi restart needed.
42
+ * snapshot() re-reads the (evicted) game_state into widgetData and re-registers
43
+ * the widget factory. Order matters: bump first, snapshot second. */
44
+ function applyChange(ctx: ExtensionContext, runtime: MegaRuntime): void {
45
+ runtime.bumpGameState();
46
+ runtime.snapshot(ctx);
47
+ }
48
+
39
49
  /** Format the current state as a human-readable status line set. */
40
50
  function fmtState(s: GameState): string[] {
41
51
  return [
@@ -55,8 +65,19 @@ async function handleSettings(
55
65
  const stateDir = runtime.currentStateDir;
56
66
  const parts = args.trim().split(/\s+/).filter(Boolean);
57
67
 
58
- // bare → print current state.
68
+ // bare → interactive in-app menu (ctx.ui.select picker). Falls back to a
69
+ // static status print when there's no interactive UI (RPC/print mode, or a
70
+ // test harness stubbing only notify). CLI subcommands below still work for
71
+ // power users + scripts.
59
72
  if (parts.length === 0) {
73
+ if (typeof ctx.ui.select === "function") {
74
+ try {
75
+ await runInteractiveMenu(ctx, runtime, stateDir);
76
+ return;
77
+ } catch {
78
+ // select threw (non-interactive impl) → fall through to status print
79
+ }
80
+ }
60
81
  const s = getGameState(stateDir);
61
82
  for (const line of fmtState(s)) ctx.ui.notify(line);
62
83
  return;
@@ -77,7 +98,7 @@ async function handleSettings(
77
98
  // on|off
78
99
  if (sub === "on" || sub === "off") {
79
100
  const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
80
- runtime.bumpGameState();
101
+ applyChange(ctx, runtime);
81
102
  ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
82
103
  return;
83
104
  }
@@ -103,7 +124,7 @@ async function handleSettings(
103
124
  return;
104
125
  }
105
126
  const s = setGameState({ theme: id }, stateDir);
106
- runtime.bumpGameState();
127
+ applyChange(ctx, runtime);
107
128
  ctx.ui.notify(`[${TAG}] theme → ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
108
129
  return;
109
130
  }
@@ -116,7 +137,7 @@ async function handleSettings(
116
137
  return;
117
138
  }
118
139
  const s = setGameState({ tui_display_mode: arg }, stateDir);
119
- runtime.bumpGameState();
140
+ applyChange(ctx, runtime);
120
141
  ctx.ui.notify(`[${TAG}] tui → ${s.tui_display_mode}`);
121
142
  return;
122
143
  }
@@ -126,6 +147,111 @@ async function handleSettings(
126
147
  );
127
148
  }
128
149
 
150
+ /** Interactive in-app menu for bare `/mega-compact-settings`. Uses ctx.ui.select
151
+ * (a real TUI picker in interactive mode). Loops until the user cancels (or
152
+ * picks "Done"). Each action mutates the global game_state row + bumps the
153
+ * runtime cache so the widget/dashboard reflect it immediately.
154
+ *
155
+ * Guarded by the caller via `typeof ctx.ui.select === "function"`; if select is
156
+ * unavailable we fall back to the static fmtState notify print. */
157
+ async function runInteractiveMenu(
158
+ ctx: ExtensionContext,
159
+ runtime: MegaRuntime,
160
+ stateDir: string,
161
+ ): Promise<void> {
162
+ for (;;) {
163
+ const s = getGameState(stateDir);
164
+ const toggleLabel = s.game_mode_on ? "Turn game mode OFF" : "Turn game mode ON";
165
+ const choice = await ctx.ui.select(
166
+ `[${TAG}] settings · game mode: ${s.game_mode_on ? "ON" : "off"} · theme: ${s.theme} · tui: ${s.tui_display_mode}`,
167
+ [toggleLabel, "Theme…", "TUI display mode…", "Achievements…", "Done"],
168
+ );
169
+ if (choice === undefined || choice === "Done") return;
170
+ if (choice === toggleLabel) {
171
+ const next = !s.game_mode_on;
172
+ setGameState({ game_mode_on: next }, stateDir);
173
+ applyChange(ctx, runtime);
174
+ ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
175
+ continue;
176
+ }
177
+ if (choice === "Theme…") {
178
+ await themeSubmenu(ctx, runtime, stateDir);
179
+ continue;
180
+ }
181
+ if (choice === "TUI display mode…") {
182
+ await tuiSubmenu(ctx, runtime, stateDir);
183
+ continue;
184
+ }
185
+ if (choice === "Achievements…") {
186
+ await achievementsView(ctx, stateDir);
187
+ continue;
188
+ }
189
+ }
190
+ }
191
+
192
+ /** Theme picker submenu — lists all themes (current marked ✓) + a cycle option. */
193
+ async function themeSubmenu(
194
+ ctx: ExtensionContext,
195
+ runtime: MegaRuntime,
196
+ stateDir: string,
197
+ ): Promise<void> {
198
+ const s = getGameState(stateDir);
199
+ const opts = THEMES.map((t) => {
200
+ const mark = t.id === s.theme ? " ✓" : "";
201
+ return `${t.id}${mark} ${t.label}`;
202
+ });
203
+ opts.push("next (cycle to next theme)");
204
+ opts.push("Back");
205
+ const choice = await ctx.ui.select(`[${TAG}] theme (current: ${s.theme})`, opts);
206
+ if (choice === undefined || choice === "Back") return;
207
+ const first = choice.split(/\s+/)[0]!;
208
+ let id: string | undefined;
209
+ if (first === "next") {
210
+ id = nextTheme(s.theme);
211
+ } else if (isValidTheme(first)) {
212
+ id = first;
213
+ }
214
+ if (id && id !== s.theme) {
215
+ setGameState({ theme: id }, stateDir);
216
+ applyChange(ctx, runtime);
217
+ ctx.ui.notify(`[${TAG}] theme → ${id} (${getTheme(id)?.label ?? ""})`, "info");
218
+ }
219
+ }
220
+
221
+ /** TUI display-mode submenu — full vs minimal (current marked ✓). */
222
+ async function tuiSubmenu(
223
+ ctx: ExtensionContext,
224
+ runtime: MegaRuntime,
225
+ stateDir: string,
226
+ ): Promise<void> {
227
+ const s = getGameState(stateDir);
228
+ const mark = (m: string) => (s.tui_display_mode === m ? " ✓" : "");
229
+ const choice = await ctx.ui.select(`[${TAG}] TUI display mode (current: ${s.tui_display_mode})`, [
230
+ `full${mark("full")} — bars, stats, flair`,
231
+ `minimal${mark("minimal")} — one-line level + cache %`,
232
+ "Back",
233
+ ]);
234
+ if (choice === undefined || choice === "Back") return;
235
+ const mode = choice.split(/\s+/)[0];
236
+ if (mode === "full" || mode === "minimal") {
237
+ setGameState({ tui_display_mode: mode }, stateDir);
238
+ applyChange(ctx, runtime);
239
+ ctx.ui.notify(`[${TAG}] tui → ${mode}`, "info");
240
+ }
241
+ }
242
+
243
+ /** Achievements view — terse notify list + a read-only select viewer. */
244
+ async function achievementsView(ctx: ExtensionContext, stateDir: string): Promise<void> {
245
+ const rows = listAchievements(stateDir).filter((r) => r.unlocked_at != null);
246
+ ctx.ui.notify(`[${TAG}] achievements unlocked (${rows.length}/9):`, "info");
247
+ for (const r of rows) ctx.ui.notify(` ${r.icon ?? ""} ${r.title}`, "info");
248
+ const lines = rows.length
249
+ ? rows.map((r) => `${r.icon ?? ""} ${r.title}`)
250
+ : ["(none unlocked yet — keep compacting!)"];
251
+ // select() as a read-only viewer; any selection / cancel returns to the menu.
252
+ await ctx.ui.select(`[${TAG}] achievements (${rows.length}/9 unlocked)`, lines);
253
+ }
254
+
129
255
  /** Register /mega-compact-settings (primary) + /mega-game (backward-compat alias). */
130
256
  export function registerGameCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
131
257
  const description =
@@ -79,6 +79,7 @@ function doCompact(
79
79
  runtime: MegaRuntime,
80
80
  ): RunCompactResult {
81
81
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
82
+ runtime.setEffect?.("pulse", "accent", 1500); // v0.8.3: ambient border pulse during compaction
82
83
  // S21.2: reset the per-compaction memory-op counter so the post-compact
83
84
  // consolidate pass only fires when memory rows actually changed during the
84
85
  // compaction window (turn_end → auto-review may have written some).
@@ -58,6 +58,64 @@ function freshDir(): string {
58
58
  return mkdtempSync(join(tmpdir(), "mc-state-"));
59
59
  }
60
60
 
61
+ describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
62
+ const dirs: string[] = [];
63
+ after(() => {
64
+ for (const d of dirs) {
65
+ try { closeStore(d); } catch { /* */ }
66
+ }
67
+ delete process.env.MEGACOMPACT_STATE_DIR;
68
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
69
+ });
70
+
71
+ it("activeEffect starts null and setEffect arms it with startedAt ~now", () => {
72
+ const dir = freshDir(); dirs.push(dir);
73
+ process.env.MEGACOMPACT_STATE_DIR = dir;
74
+ const rt = new MegaRuntime(minimalConfig(dir));
75
+ assert.equal(rt.activeEffect, null, "idle on construction");
76
+ const before = Date.now();
77
+ rt.setEffect("pulse", "accent", 2000);
78
+ const after = Date.now();
79
+ assert.equal(rt.activeEffect.type, "pulse");
80
+ assert.equal(rt.activeEffect.role, "accent");
81
+ assert.equal(rt.activeEffect.durationMs, 2000);
82
+ assert.ok(
83
+ rt.activeEffect.startedAt >= before && rt.activeEffect.startedAt <= after,
84
+ "startedAt within the call window",
85
+ );
86
+ });
87
+
88
+ it("setEffect replaces an in-flight effect (last call wins)", () => {
89
+ const dir = freshDir(); dirs.push(dir);
90
+ process.env.MEGACOMPACT_STATE_DIR = dir;
91
+ const rt = new MegaRuntime(minimalConfig(dir));
92
+ rt.setEffect("pulse", "accent", 2000);
93
+ const first = rt.activeEffect;
94
+ rt.setEffect("flash", "mega", 1200);
95
+ assert.notEqual(rt.activeEffect, first, "a fresh object replaced the prior");
96
+ assert.equal(rt.activeEffect.type, "flash");
97
+ assert.equal(rt.activeEffect.role, "mega");
98
+ assert.equal(rt.activeEffect.durationMs, 1200);
99
+ });
100
+
101
+ it("a backdated effect is recognized as expired by the snapshot predicate", () => {
102
+ const dir = freshDir(); dirs.push(dir);
103
+ process.env.MEGACOMPACT_STATE_DIR = dir;
104
+ const rt = new MegaRuntime(minimalConfig(dir));
105
+ rt.setEffect("pulse", "accent", 2000);
106
+ // Backdate startedAt past the duration window (no snapshot/ctx needed —
107
+ // this mirrors the exact predicate snapshot() runs after the flare consume).
108
+ rt.activeEffect.startedAt = Date.now() - 3000;
109
+ const expired =
110
+ !!rt.activeEffect &&
111
+ Date.now() - rt.activeEffect.startedAt >= rt.activeEffect.durationMs;
112
+ assert.ok(expired, "backdated effect satisfies the expiry predicate");
113
+ // Clearing mirrors the snapshot() bookkeeping branch:
114
+ if (expired) rt.activeEffect = null;
115
+ assert.equal(rt.activeEffect, null, "cleared once expired");
116
+ });
117
+ });
118
+
61
119
  describe("MegaRuntime game-state cache (S31)", () => {
62
120
  const dirs: string[] = [];
63
121
  after(() => {
@@ -97,6 +97,12 @@ export class MegaRuntime {
97
97
  // when cachePct > 100). Copied into widgetData.megaCacheFlare on the next
98
98
  // snapshot() so the widget renders the oopsie gag, then reset (one cycle).
99
99
  megaCacheFlare = false;
100
+ /** v0.8.3: ambient effect state for animated panel borders keyed off
101
+ * status transitions (level-up, mega-cache overshoot, achievement unlock,
102
+ * compaction start). Threaded into widgetData as `activeEffect`; the widget
103
+ * computes the per-frame phase from startedAt vs Date.now() (non-expired).
104
+ * Null when idle/expired. */
105
+ activeEffect: { type: "pulse" | "flash"; role: "accent" | "mega" | "red"; startedAt: number; durationMs: number } | null = null;
100
106
  megaCacheFlarePct = 0;
101
107
  levelUpFlare = false;
102
108
  lastLevel = 0;
@@ -575,7 +581,11 @@ export class MegaRuntime {
575
581
  const gs = this.getCachedGameState();
576
582
  // S34: derive the level-up flare from the turn count each snapshot.
577
583
  const curLevel = this.getTurnLevel();
578
- if (curLevel > this.lastLevel) this.levelUpFlare = true;
584
+ if (curLevel > this.lastLevel) {
585
+ this.levelUpFlare = true;
586
+ // v0.8.3: arm a pulse border effect to celebrate the level-up.
587
+ this.setEffect("pulse", "accent", 1500);
588
+ }
579
589
  const cachePct = st.dedupHitRate * 100;
580
590
  this.widgetData = {
581
591
  version: ownVersion(),
@@ -619,6 +629,9 @@ export class MegaRuntime {
619
629
  levelUpFlare: this.levelUpFlare,
620
630
  achievementFlare: this.achievementFlare,
621
631
  achievementFlareTitles: this.achievementFlareTitles,
632
+ // v0.8.3: ambient border effect — threaded live so the widget can
633
+ // compute the per-frame phase and render animated borders.
634
+ activeEffect: this.activeEffect,
622
635
  };
623
636
  // S33: consume the flare after copying it into widgetData so it fires
624
637
  // for exactly one render cycle (the gag flares once, then clears).
@@ -633,6 +646,19 @@ export class MegaRuntime {
633
646
  // (mirrors the megaCacheFlare/levelUpFlare one-shot semantics).
634
647
  this.achievementFlare = false;
635
648
  this.achievementFlareTitles = [];
649
+ // v0.8.3: expire the ambient border effect once its time window has
650
+ // elapsed. SEPARATE from the one-shot flares above (those are per-cycle
651
+ // consumes; activeEffect is time-windowed and cleared when Date.now()
652
+ // crosses startedAt + durationMs). The widget also defends this per-frame
653
+ // (effectBorderSgr returns '' once expired), so this is bookkeeping to
654
+ // free the slot and prevent a stale effect lingering between snapshots.
655
+ if (
656
+ this.activeEffect &&
657
+ Date.now() - this.activeEffect.startedAt >=
658
+ this.activeEffect.durationMs
659
+ ) {
660
+ this.activeEffect = null;
661
+ }
636
662
  // Auto-fit: register a factory so pi re-renders the panel at the REAL
637
663
  // terminal width every frame (tui.columns), instead of guessing with
638
664
  // process.stdout.columns. buildWidgetLines reads this.widgetData live.
@@ -915,18 +941,37 @@ export class MegaRuntime {
915
941
  }
916
942
 
917
943
  /** S33: arm the transient MEGA CACHE flare so the next snapshot() copies it
918
- * into widgetData and the widget renders the oopsie gag for one cycle. */
944
+ * into widgetData and the widget renders the oopsie gag for one cycle.
945
+ * v0.8.3: also arm a 'flash' ambient effect on the panel borders (mega
946
+ * color) for 1.2s. */
919
947
  armMegaCacheFlare(peakPct: number): void {
920
948
  this.megaCacheFlare = true;
921
949
  this.megaCacheFlarePct = peakPct;
950
+ this.setEffect("flash", "mega", 1200);
922
951
  }
923
952
 
924
953
  /** S35: arm the transient achievement-unlock flare with the newly-unlocked
925
954
  * titles so the next snapshot() copies them into widgetData and the widget
926
- * renders the one-time unlock toast for one render cycle. */
955
+ * renders the one-time unlock toast for one render cycle.
956
+ * v0.8.3: also arm a 'pulse' ambient effect on the panel borders (accent
957
+ * color) for 2s to celebrate the unlock. */
927
958
  armAchievementFlare(titles: string[]): void {
928
959
  this.achievementFlare = true;
929
960
  this.achievementFlareTitles = titles;
961
+ this.setEffect("pulse", "accent", 2000);
962
+ }
963
+
964
+ /** v0.8.3: arm an ambient border effect (animated pulse/flash on the panel
965
+ * borders). Replaces any in-flight effect (last call wins — a later event
966
+ * like a level-up during an achievement pulse simply overrides). The widget
967
+ * reads activeEffect each frame and computes the per-frame phase from
968
+ * startedAt vs Date.now(); it renders '' once the window elapses. */
969
+ setEffect(
970
+ type: "pulse" | "flash",
971
+ role: "accent" | "mega" | "red",
972
+ durationMs: number,
973
+ ): void {
974
+ this.activeEffect = { type, role, startedAt: Date.now(), durationMs };
930
975
  }
931
976
 
932
977
  /** Build the sync onTier callback that paints the live per-tier trace. */
@@ -162,6 +162,56 @@ describe("buildWidgetLines (S31)", () => {
162
162
  });
163
163
 
164
164
 
165
+ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
166
+ const effBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
167
+ theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
168
+ });
169
+ const isBorder = (l: string): boolean => l.includes("─");
170
+
171
+ it("activeEffect (pulse, mid-window) -> border lines carry a 256-color fg SGR", () => {
172
+ const ae = { type: "pulse" as const, role: "accent" as const, startedAt: Date.now() - 250, durationMs: 2000 };
173
+ const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
174
+ const borders = lines.filter(isBorder);
175
+ assert.ok(borders.length >= 2, "has top + bottom borders");
176
+ for (const b of borders) {
177
+ assert.ok(b.includes("\x1b[38;5;"), `border carries 256-color fg: ${JSON.stringify(b)}`);
178
+ }
179
+ });
180
+
181
+ it("activeEffect null -> plain borders, no 38;5 fg SGR on border lines", () => {
182
+ const lines = buildWidgetLines(effBase({ activeEffect: null }), WIDTH, 0);
183
+ const borders = lines.filter(isBorder);
184
+ for (const b of borders) {
185
+ assert.ok(!b.includes("\x1b[38;5;"), `no effect SGR on plain border: ${JSON.stringify(b)}`);
186
+ }
187
+ });
188
+
189
+ it("expired activeEffect -> plain borders (per-frame expiry enforced)", () => {
190
+ const ae = { type: "pulse" as const, role: "accent" as const, startedAt: Date.now() - 5000, durationMs: 1000 };
191
+ const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
192
+ const borders = lines.filter(isBorder);
193
+ for (const b of borders) {
194
+ assert.ok(!b.includes("\x1b[38;5;"), `expired effect -> plain border: ${JSON.stringify(b)}`);
195
+ }
196
+ });
197
+
198
+ it("activeEffect border lines are width-safe (pulse, minimal + full)", () => {
199
+ for (const tuiMode of ["minimal", "full"] as const) {
200
+ const ae = { type: "pulse" as const, role: "mega" as const, startedAt: Date.now() - 100, durationMs: 2000 };
201
+ const lines = buildWidgetLines(effBase({ activeEffect: ae, tuiMode }), 60, 0);
202
+ for (const l of lines) assert.ok(visibleWidth(l) <= 60, `width safe (${tuiMode}): ${visibleWidth(l)}`);
203
+ }
204
+ });
205
+
206
+ it("flash effect mid-window border carries the full base index SGR", () => {
207
+ // Force an 'on' phase of the 120ms alternate by starting just now.
208
+ const ae = { type: "flash" as const, role: "red" as const, startedAt: Date.now(), durationMs: 1200 };
209
+ const lines = buildWidgetLines(effBase({ activeEffect: ae }), WIDTH, 0);
210
+ const borders = lines.filter(isBorder);
211
+ assert.ok(borders.some((b) => b.includes("\x1b[38;5;203m")), `flash-on phase uses red base 203`);
212
+ });
213
+ });
214
+
165
215
  describe("buildWidgetLines achievement flare (S35)", () => {
166
216
  const achBase = (overrides: Partial<WidgetData> = {}): WidgetData => baseWd({
167
217
  theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,
@@ -128,6 +128,51 @@ function panelBar(width: number, ch = "─", panelBg: string = DEFAULT_PANEL_BG)
128
128
  return truncateToWidth(panelBg + ch.repeat(Math.max(0, width)), width, "", false) + "\x1b[0m";
129
129
  }
130
130
 
131
+ // ── v0.8.3: ambient border-effect helpers ───────────────────────────────
132
+ // The panel borders animate when an `activeEffect` is armed (level-up,
133
+ // mega-cache overshoot, achievement unlock, compaction start). Two modes:
134
+ // • pulse — a sine ramp on a 256-color base (accent=51 / mega=214 / red=203):
135
+ // the base index is scaled by sin(π·t) so the border swells 0→peak→0 over
136
+ // the duration, then returns to '' (idle). 256-color indices are clamped
137
+ // to 0–255 defensively (the bases are all ≤214 so the clamp rarely bites).
138
+ // • flash — a 120ms hard on/off alternate using the base index at full.
139
+ // Returns '' when idle, expired, or elapsed<0 (clock skew) so non-effect
140
+ // renders are byte-identical to the pre-effect panel (S31 matrix stays green).
141
+ const EFFECT_BASE: Record<"accent" | "mega" | "red", number> = {
142
+ accent: 51,
143
+ mega: 214,
144
+ red: 203,
145
+ };
146
+
147
+ /** Resolve the per-frame border-fg SGR for an active effect. '' when idle or
148
+ * expired (the widget's real per-frame expiry enforcer — snapshot-level clear
149
+ * is just bookkeeping since snapshot is event-driven). */
150
+ function effectBorderSgr(
151
+ ae: NonNullable<WidgetData["activeEffect"]> | null,
152
+ now: number,
153
+ ): string {
154
+ if (!ae) return "";
155
+ const elapsed = now - ae.startedAt;
156
+ if (elapsed < 0 || elapsed >= ae.durationMs) return "";
157
+ const base = EFFECT_BASE[ae.role];
158
+ if (ae.type === "flash") {
159
+ // 120ms hard alternate: on (full base) / off (no SGR).
160
+ return Math.floor(elapsed / 120) % 2 === 0 ? `\x1b[38;5;${base}m` : "";
161
+ }
162
+ // pulse: sine ramp 0 → peak → 0 over the duration.
163
+ const t = elapsed / ae.durationMs;
164
+ const amp = Math.sin(Math.PI * t); // 0 at start/end, 1 at midpoint
165
+ const idx = Math.max(0, Math.min(255, Math.round(base * amp)));
166
+ return `\x1b[38;5;${idx}m`;
167
+ }
168
+
169
+ /** Prepend the effect border SGR to a panel bar line. The SGR is a pure-fg
170
+ * escape (zero visible width), so it never perturbs truncateToWidth's width
171
+ * math — the bar's own `\x1b[0m` tail resets both fg + bg. No-op when sgr=''. */
172
+ function effectBar(bar: string, sgr: string): string {
173
+ return sgr ? sgr + bar : bar;
174
+ }
175
+
131
176
  /** Token-count formatter: M at/above 1e6, k at/above 1e3, raw below.
132
177
  * 5,472,700 → "5.5mil", 24,100 → "24.1k", 142 → "142". */
133
178
  function fmtTokens(x: number): string {
@@ -231,6 +276,15 @@ export interface WidgetData {
231
276
  /** S35: achievement-unlock flare -- renders a one-line toast for one cycle. */
232
277
  achievementFlare?: boolean;
233
278
  achievementFlareTitles?: string[];
279
+ /** v0.8.3: ambient animated border effect (null when idle/expired). The
280
+ * widget computes the per-frame phase from startedAt vs Date.now() and
281
+ * renders a pulse/flash on the panel borders; '' once the window elapses. */
282
+ activeEffect?: {
283
+ type: "pulse" | "flash";
284
+ role: "accent" | "mega" | "red";
285
+ startedAt: number;
286
+ durationMs: number;
287
+ } | null;
234
288
  }
235
289
 
236
290
  // ── buildWidgetLines ───────────────────────────────────────────────────────
@@ -250,11 +304,16 @@ export function buildWidgetLines(
250
304
  // every panelLine/panelBar/wrapLine so the bg stays continuous and the width
251
305
  // guard (truncateToWidth) still holds for transparent themes.
252
306
  const panelBg = wd?.theme ? panelBgFor(wd.theme) : DEFAULT_PANEL_BG;
307
+ // v0.8.3: resolve the animated border SGR once per render. '' when idle,
308
+ // expired, or wd is null (warm-up) — so non-effect renders are byte-identical
309
+ // to the pre-effect panel (the existing S31 matrix tests stay green).
310
+ const now = Date.now();
311
+ const borderSgr = effectBorderSgr(wd?.activeEffect ?? null, now);
253
312
  if (!wd) {
254
313
  return [
255
- panelBar(width, "─", panelBg),
314
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
256
315
  panelLine(" mega-compact: warming up…", width, panelBg),
257
- panelBar(width, "─", panelBg),
316
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
258
317
  ];
259
318
  }
260
319
  // S31: minimal TUI mode — a single content line `LVL n | cache NN%` flanked
@@ -276,9 +335,9 @@ export function buildWidgetLines(
276
335
  ? `${accent}${wd.gameMode && wd.levelUpFlare ? "\x1b[5m" : ""}LVL ${lvl}${wd.gameMode && wd.levelUpFlare ? "\x1b[0m" : ""}${sgrReset(accent)} ${C.dim}|${C.reset} cache ${cacheStr}${megaFlare}`
277
336
  : `cache ${cacheStr}${megaFlare}`;
278
337
  return [
279
- panelBar(width, "─", panelBg),
338
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
280
339
  panelLine(` ${body}`, width, panelBg),
281
- panelBar(width, "─", panelBg),
340
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
282
341
  ];
283
342
  }
284
343
  const pulse = wd.pulsing
@@ -312,7 +371,7 @@ export function buildWidgetLines(
312
371
  // Wrap to terminal width and pad each line
313
372
  const wrapped = wrapLine(content, width - 2, panelBg); // 2-char indent
314
373
  const lines: string[] = [
315
- panelBar(width, "─", panelBg),
374
+ effectBar(panelBar(width, "─", panelBg), borderSgr),
316
375
  ...wrapped.map((l) => panelLine(l, width, panelBg)),
317
376
  ];
318
377
  // L4 — agents block (S27, count + status; per-agent tokens gated on P0)
@@ -354,6 +413,6 @@ export function buildWidgetLines(
354
413
  lines.push(panelLine(` ${accentSgr}🏆 Achievement unlocked: ${titlesStr}${sgrReset(accentSgr)}`, width, panelBg));
355
414
  }
356
415
  // bottom border
357
- lines.push(panelBar(width, "─", panelBg));
416
+ lines.push(effectBar(panelBar(width, "─", panelBg), borderSgr));
358
417
  return lines;
359
418
  }
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.3",
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",