pi-mega-compact 0.8.2 โ†’ 0.8.4

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.
@@ -932,7 +932,7 @@ export function dashboardHtml(tierName) {
932
932
  if (best != null && best > 100) {
933
933
  tile.style.display = 'block';
934
934
  tile.className = 'achievement-tile unlocked';
935
- tile.innerHTML = '๐Ÿ† Opie\'s Wild Ride<span class="ach-detail">best ' + sanitize(fmtPct(best)) + ' ยท first ' + sanitize(fmtDate(firstSeen)) + '</span>';
935
+ tile.innerHTML = '๐Ÿ† Opie\\'s Wild Ride<span class="ach-detail">best ' + sanitize(fmtPct(best)) + ' ยท first ' + sanitize(fmtDate(firstSeen)) + '</span>';
936
936
  } else { tile.style.display = 'none'; tile.className = 'achievement-tile'; }
937
937
  }
938
938
  // transient oopsie toast: a NEW mega_cache trophy row since the last poll
@@ -39,3 +39,14 @@ describe("S35 dashboard HTML achievements", () => {
39
39
  assert.ok(html.includes("??? "), "??? teaser present");
40
40
  });
41
41
  });
42
+ describe("S35 dashboard HTML achievements escaping (v0.8.4 fix)", () => {
43
+ const html = dashboardHtml("custom");
44
+ test("Opie's Wild Ride JS string is backslash-escaped (no bare apostrophe)", () => {
45
+ // The served <script> uses single-quoted JS strings: '๐Ÿ† Opie\'s Wild Ride...'.
46
+ // A bare apostrophe (Opie's) would terminate the string and halt the script,
47
+ // breaking .tab click bindings. The served HTML MUST contain the escaped
48
+ // form Opie\'s and MUST NOT contain the bare form '๐Ÿ† Opie's'.
49
+ assert.ok(html.includes("๐Ÿ† Opie\\'s Wild Ride"), "served JS contains Opie\\'s (escaped)");
50
+ assert.ok(!html.includes("๐Ÿ† Opie's Wild Ride"), "no bare Opie's in served JS");
51
+ });
52
+ });
@@ -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 [
@@ -72,7 +81,7 @@ async function handleSettings(args, ctx, runtime) {
72
81
  // on|off
73
82
  if (sub === "on" || sub === "off") {
74
83
  const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
75
- runtime.bumpGameState();
84
+ applyChange(ctx, runtime);
76
85
  ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
77
86
  return;
78
87
  }
@@ -99,7 +108,7 @@ async function handleSettings(args, ctx, runtime) {
99
108
  return;
100
109
  }
101
110
  const s = setGameState({ theme: id }, stateDir);
102
- runtime.bumpGameState();
111
+ applyChange(ctx, runtime);
103
112
  ctx.ui.notify(`[${TAG}] theme โ†’ ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
104
113
  return;
105
114
  }
@@ -111,7 +120,7 @@ async function handleSettings(args, ctx, runtime) {
111
120
  return;
112
121
  }
113
122
  const s = setGameState({ tui_display_mode: arg }, stateDir);
114
- runtime.bumpGameState();
123
+ applyChange(ctx, runtime);
115
124
  ctx.ui.notify(`[${TAG}] tui โ†’ ${s.tui_display_mode}`);
116
125
  return;
117
126
  }
@@ -134,7 +143,7 @@ async function runInteractiveMenu(ctx, runtime, stateDir) {
134
143
  if (choice === toggleLabel) {
135
144
  const next = !s.game_mode_on;
136
145
  setGameState({ game_mode_on: next }, stateDir);
137
- runtime.bumpGameState();
146
+ applyChange(ctx, runtime);
138
147
  ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
139
148
  continue;
140
149
  }
@@ -174,7 +183,7 @@ async function themeSubmenu(ctx, runtime, stateDir) {
174
183
  }
175
184
  if (id && id !== s.theme) {
176
185
  setGameState({ theme: id }, stateDir);
177
- runtime.bumpGameState();
186
+ applyChange(ctx, runtime);
178
187
  ctx.ui.notify(`[${TAG}] theme โ†’ ${id} (${getTheme(id)?.label ?? ""})`, "info");
179
188
  }
180
189
  }
@@ -192,7 +201,7 @@ async function tuiSubmenu(ctx, runtime, stateDir) {
192
201
  const mode = choice.split(/\s+/)[0];
193
202
  if (mode === "full" || mode === "minimal") {
194
203
  setGameState({ tui_display_mode: mode }, stateDir);
195
- runtime.bumpGameState();
204
+ applyChange(ctx, runtime);
196
205
  ctx.ui.notify(`[${TAG}] tui โ†’ ${mode}`, "info");
197
206
  }
198
207
  }
@@ -16,10 +16,16 @@ 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, select) {
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
31
  ui: { notify: (s) => notifies.push(s), ...(select ? { select } : {}) },
@@ -33,7 +39,7 @@ function makeHarness(stateDir, select) {
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;
@@ -92,6 +98,16 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
92
98
  assert.equal(getGameState().theme, "retro");
93
99
  assert.ok(h.notifies.some((l) => l.includes("theme โ†’ retro")));
94
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
+ });
95
111
  it("on enables game mode and persists", async () => {
96
112
  await run("on");
97
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,
@@ -48,3 +48,15 @@ describe("S35 dashboard HTML achievements", () => {
48
48
  assert.ok(html.includes("??? "), "??? teaser present");
49
49
  });
50
50
  });
51
+
52
+ describe("S35 dashboard HTML achievements escaping (v0.8.4 fix)", () => {
53
+ const html = dashboardHtml("custom");
54
+ test("Opie's Wild Ride JS string is backslash-escaped (no bare apostrophe)", () => {
55
+ // The served <script> uses single-quoted JS strings: '๐Ÿ† Opie\'s Wild Ride...'.
56
+ // A bare apostrophe (Opie's) would terminate the string and halt the script,
57
+ // breaking .tab click bindings. The served HTML MUST contain the escaped
58
+ // form Opie\'s and MUST NOT contain the bare form '๐Ÿ† Opie's'.
59
+ assert.ok(html.includes("๐Ÿ† Opie\\'s Wild Ride"), "served JS contains Opie\\'s (escaped)");
60
+ assert.ok(!html.includes("๐Ÿ† Opie's Wild Ride"), "no bare Opie's in served JS");
61
+ });
62
+ });
@@ -935,7 +935,7 @@ export function dashboardHtml(tierName: string): string {
935
935
  if (best != null && best > 100) {
936
936
  tile.style.display = 'block';
937
937
  tile.className = 'achievement-tile unlocked';
938
- tile.innerHTML = '๐Ÿ† Opie\'s Wild Ride<span class="ach-detail">best ' + sanitize(fmtPct(best)) + ' ยท first ' + sanitize(fmtDate(firstSeen)) + '</span>';
938
+ tile.innerHTML = '๐Ÿ† Opie\\'s Wild Ride<span class="ach-detail">best ' + sanitize(fmtPct(best)) + ' ยท first ' + sanitize(fmtDate(firstSeen)) + '</span>';
939
939
  } else { tile.style.display = 'none'; tile.className = 'achievement-tile'; }
940
940
  }
941
941
  // transient oopsie toast: a NEW mega_cache trophy row since the last poll
@@ -24,15 +24,23 @@ type Harness = {
24
24
  commands: Record<string, Cmd>;
25
25
  notifies: string[];
26
26
  ctx: any;
27
+ snapshotCalls: number[];
27
28
  };
28
29
 
29
30
  function makeHarness(
30
31
  stateDir: string,
31
32
  select?: (title: string, options: string[]) => Promise<string | undefined>,
33
+ snapshotSpy?: () => void,
32
34
  ): Harness {
33
35
  const commands: Record<string, Cmd> = {};
34
36
  const notifies: string[] = [];
35
- 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
+ };
36
44
  const ctx = {
37
45
  cwd: stateDir,
38
46
  ui: { notify: (s: string) => notifies.push(s), ...(select ? { select } : {}) },
@@ -48,7 +56,7 @@ function makeHarness(
48
56
  registerGameCommands: (pi: unknown, runtime: unknown) => void;
49
57
  };
50
58
  mod.registerGameCommands(fakePi, runtime);
51
- return { commands, notifies, ctx };
59
+ return { commands, notifies, ctx, snapshotCalls };
52
60
  }
53
61
 
54
62
  describe("/mega-compact-settings (S30; /mega-game alias)", () => {
@@ -115,6 +123,17 @@ describe("/mega-compact-settings (S30; /mega-game alias)", () => {
115
123
  assert.ok(h.notifies.some((l) => l.includes("theme โ†’ retro")));
116
124
  });
117
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
+
118
137
  it("on enables game mode and persists", async () => {
119
138
  await run("on");
120
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 [
@@ -88,7 +98,7 @@ async function handleSettings(
88
98
  // on|off
89
99
  if (sub === "on" || sub === "off") {
90
100
  const s = setGameState({ game_mode_on: sub === "on" }, stateDir);
91
- runtime.bumpGameState();
101
+ applyChange(ctx, runtime);
92
102
  ctx.ui.notify(`[${TAG}] game mode ${s.game_mode_on ? "ON" : "off"}`);
93
103
  return;
94
104
  }
@@ -114,7 +124,7 @@ async function handleSettings(
114
124
  return;
115
125
  }
116
126
  const s = setGameState({ theme: id }, stateDir);
117
- runtime.bumpGameState();
127
+ applyChange(ctx, runtime);
118
128
  ctx.ui.notify(`[${TAG}] theme โ†’ ${s.theme} (${getTheme(s.theme)?.label ?? ""})`);
119
129
  return;
120
130
  }
@@ -127,7 +137,7 @@ async function handleSettings(
127
137
  return;
128
138
  }
129
139
  const s = setGameState({ tui_display_mode: arg }, stateDir);
130
- runtime.bumpGameState();
140
+ applyChange(ctx, runtime);
131
141
  ctx.ui.notify(`[${TAG}] tui โ†’ ${s.tui_display_mode}`);
132
142
  return;
133
143
  }
@@ -160,7 +170,7 @@ async function runInteractiveMenu(
160
170
  if (choice === toggleLabel) {
161
171
  const next = !s.game_mode_on;
162
172
  setGameState({ game_mode_on: next }, stateDir);
163
- runtime.bumpGameState();
173
+ applyChange(ctx, runtime);
164
174
  ctx.ui.notify(`[${TAG}] game mode ${next ? "ON" : "off"}`, "info");
165
175
  continue;
166
176
  }
@@ -203,7 +213,7 @@ async function themeSubmenu(
203
213
  }
204
214
  if (id && id !== s.theme) {
205
215
  setGameState({ theme: id }, stateDir);
206
- runtime.bumpGameState();
216
+ applyChange(ctx, runtime);
207
217
  ctx.ui.notify(`[${TAG}] theme โ†’ ${id} (${getTheme(id)?.label ?? ""})`, "info");
208
218
  }
209
219
  }
@@ -225,7 +235,7 @@ async function tuiSubmenu(
225
235
  const mode = choice.split(/\s+/)[0];
226
236
  if (mode === "full" || mode === "minimal") {
227
237
  setGameState({ tui_display_mode: mode }, stateDir);
228
- runtime.bumpGameState();
238
+ applyChange(ctx, runtime);
229
239
  ctx.ui.notify(`[${TAG}] tui โ†’ ${mode}`, "info");
230
240
  }
231
241
  }
@@ -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.2",
3
+ "version": "0.8.4",
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",