pi-magi-theme 0.2.3 → 0.2.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.
package/README.md CHANGED
@@ -72,6 +72,8 @@ The window title is `π - Magi - <working directory>`. After a run longer than 3
72
72
 
73
73
  In fullscreen mode the side panel always reaches the bottom of the terminal.
74
74
 
75
+ In a terminal too narrow for it, the footer scrolls as one line instead of being cut off.
76
+
75
77
  ## The seventh seal: smart compaction
76
78
 
77
79
  The theme does not compact anything itself: it shows who does. For better compaction install [pi-smart-compact](https://www.npmjs.com/package/pi-smart-compact):
@@ -104,15 +106,27 @@ Each nature is a lens, not a specialty, so the council answers any question, not
104
106
  {
105
107
  "MELCHIOR": { "model": "llama-swap/Qwen3.8 27B Q4_K_M - Thinking", "thinking": "low" },
106
108
  "ui": { "compact": false, "kwhPrice": 0.30, "currency": "EUR" },
107
- "loads": { "qwen3.8-27b": 41200 }
109
+ "loads": { "qwen3.8-27b": 41200 },
110
+ "totalWh": 1843.2
108
111
  }
109
112
  ```
110
113
 
111
114
  - per MAGI: `model` (unset = current session model) and optional `thinking` level;
112
115
  - `ui.compact`: start with the compact side panel;
113
- - `ui.kwhPrice` and `ui.currency` (`EUR` or `USD`): the COST row multiplies the GPU energy used in the session by this price;
116
+ - `ui.kwhPrice` and `ui.currency` (`EUR` or `USD`): the COST row multiplies the GPU energy by this price, showing the running total of every session with the current one in brackets;
117
+ - `totalWh`: written by the theme, GPU energy summed over every session (delete the key to reset the COST total);
114
118
  - `loads`: written by the theme, how long each llama-swap model took to load last time (paces the angel attack; 60s when unknown).
115
119
 
120
+ ## Release
121
+
122
+ `.github/workflows/publish.yml` publishes to npm when a `v*` tag is pushed, and refuses if the tag does not match `package.json`:
123
+
124
+ ```
125
+ npm version patch && git push --follow-tags
126
+ ```
127
+
128
+ No token: npmjs is configured to trust this repository's `publish.yml` (npm trusted publishing, OIDC), which also signs the provenance.
129
+
116
130
  ## llama-swap
117
131
 
118
132
  When the session model uses the `llama-swap` provider, the side panel:
@@ -32,7 +32,7 @@ import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
32
32
  import { completeSimple } from "@earendil-works/pi-ai";
33
33
  import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
34
34
  import type { Component, OverlayHandle, TUI } from "@earendil-works/pi-tui";
35
- import { HStack, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
35
+ import { HStack, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
36
36
 
37
37
  /* ────────────────────────────────────────────────────────────── art ── */
38
38
 
@@ -340,10 +340,15 @@ function setPhase(p: Phase): void {
340
340
  }
341
341
 
342
342
  const ANIM_STEP_MS = 220;
343
+ /** How long one Sephirah stays lit in the footer: its name and meaning are text, they need time to be read. */
344
+ const SEPHIRAH_STEP_MS = 1400;
343
345
  const FAIL_FLASH_MS = 2500;
344
346
  const REBIRTH_MS = 6000;
345
347
  const DONE_TITLE_AFTER_MS = 30_000;
346
348
  const SIXTH_SEAL_PERCENT = (6 / 7) * 100;
349
+ /** Footer marquee: one cell every FOOTER_SCROLL_MS, with this gap between the loop's end and its start. */
350
+ const FOOTER_SCROLL_MS = 260;
351
+ const FOOTER_SCROLL_GAP = " · ";
347
352
 
348
353
  /** Sync ratio: share of tool calls that succeeded (null before the first tool). */
349
354
  function syncPercent(): number | null {
@@ -710,6 +715,8 @@ const swap = {
710
715
  cacheTokens: 0,
711
716
  inputTokens: 0,
712
717
  energyWh: 0, // GPU energy since the session started
718
+ diskWh: 0, // GPU energy of all sessions, as last read from/written to magi.json
719
+ savedWh: 0, // the part of energyWh already added to diskWh
713
720
  lastSampleAt: 0,
714
721
  lastWatts: 0,
715
722
  };
@@ -760,6 +767,34 @@ function sampleEnergy(now = Date.now()): void {
760
767
  }
761
768
  swap.lastSampleAt = now;
762
769
  swap.lastWatts = watts;
770
+ persistEnergy();
771
+ }
772
+
773
+ /** GPU energy of every session so far: what is on disk plus this session's not-yet-written part. */
774
+ function totalWh(): number {
775
+ return swap.diskWh + swap.energyWh - swap.savedWh;
776
+ }
777
+
778
+ const ENERGY_SAVE_MS = 60_000;
779
+ let lastPersistAt = 0;
780
+
781
+ /**
782
+ * Adds this session's new energy to the running total in magi.json, so the COST row survives restarts.
783
+ * ponytail: writes at most once a minute, and adds a delta so parallel sessions do not overwrite each other.
784
+ */
785
+ function persistEnergy(force = false): void {
786
+ const delta = swap.energyWh - swap.savedWh;
787
+ if (delta <= 0 || (!force && Date.now() - lastPersistAt < ENERGY_SAVE_MS)) return;
788
+ lastPersistAt = Date.now();
789
+ const cfg = loadMagiConfig();
790
+ cfg.totalWh = (cfg.totalWh ?? 0) + delta;
791
+ try {
792
+ saveMagiConfig(cfg);
793
+ } catch {
794
+ return; // disk unavailable: keep the delta and retry on the next sample
795
+ }
796
+ swap.savedWh = swap.energyWh;
797
+ swap.diskWh = cfg.totalWh;
763
798
  }
764
799
 
765
800
  async function refreshSwapMetrics(): Promise<void> {
@@ -1311,11 +1346,14 @@ class MagiPanel implements Component {
1311
1346
  return out;
1312
1347
  }
1313
1348
 
1314
- /** COST: GPU energy used in the session × price per kWh set with /magi-ui config. */
1349
+ /** COST: GPU energy × price per kWh set with /magi-ui config — all sessions, with this one in brackets. */
1315
1350
  private costRow(inner: number): string {
1316
1351
  if (!swap.gpus.length) return this.field("COST", "—", inner, "muted");
1317
1352
  if (ui.kwhPrice === undefined) return this.field("COST", "→ /magi-ui config", inner, "dim");
1318
- return this.field("COST", fmtMoney((swap.energyWh / 1000) * ui.kwhPrice), inner, "warning");
1353
+ const th = this.theme;
1354
+ const price = (wh: number) => fmtMoney((wh / 1000) * ui.kwhPrice!);
1355
+ const label = th.fg("dim", "COST".padEnd(9));
1356
+ return this.frameLine(" " + label + th.fg("warning", price(totalWh())) + th.fg("dim", ` (ses ${price(swap.energyWh)})`), inner);
1319
1357
  }
1320
1358
 
1321
1359
  private swapRows(inner: number, compact: boolean): string[] {
@@ -1357,7 +1395,7 @@ class MagiPanel implements Component {
1357
1395
  );
1358
1396
  }
1359
1397
  if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
1360
- if (swap.gpus.length) out.push(this.field("ENERGY", `${(swap.energyWh / 1000).toFixed(3)} kWh`, inner, "muted"));
1398
+ if (swap.gpus.length) out.push(this.field("ENERGY", `${(totalWh() / 1000).toFixed(3)} kWh`, inner, "muted"));
1361
1399
  out.push(this.costRow(inner));
1362
1400
  return out;
1363
1401
  }
@@ -1466,6 +1504,7 @@ interface MagiUnitConfig {
1466
1504
  type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & {
1467
1505
  ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency };
1468
1506
  loads?: Record<string, number>; // real model id → ms its last load took
1507
+ totalWh?: number; // GPU energy summed over every session, for the COST row
1469
1508
  };
1470
1509
 
1471
1510
  const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
@@ -1807,6 +1846,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1807
1846
  function footerLeft(th: Theme, now = Date.now()): string {
1808
1847
  const dim = (s: string) => th.fg("dim", s);
1809
1848
  const step = Math.floor(now / ANIM_STEP_MS);
1849
+ const slowStep = Math.floor(now / SEPHIRAH_STEP_MS);
1810
1850
  const lights = (fn: (i: number) => NodeLight) => SEPHIROT.map((_, i) => fn(i));
1811
1851
 
1812
1852
  if (state.compacting) {
@@ -1860,7 +1900,7 @@ function footerLeft(th: Theme, now = Date.now()): string {
1860
1900
  );
1861
1901
  }
1862
1902
  if (state.phase === "thinking") {
1863
- const cur = Math.floor(step / 2) % 3; // Keter, Chokmah, Binah
1903
+ const cur = slowStep % 3; // Keter, Chokmah, Binah
1864
1904
  const s = SEPHIROT[cur]!;
1865
1905
  return (
1866
1906
  th.fg("accent", "◆ ") +
@@ -1870,7 +1910,7 @@ function footerLeft(th: Theme, now = Date.now()): string {
1870
1910
  );
1871
1911
  }
1872
1912
  if (state.phase === "responding") {
1873
- const cur = 5 + (step % 5); // Tiferet → Malkuth
1913
+ const cur = 5 + (slowStep % 5); // Tiferet → Malkuth
1874
1914
  const s = SEPHIROT[cur]!;
1875
1915
  const tps = liveTps();
1876
1916
  return (
@@ -1908,9 +1948,21 @@ function footerLeft(th: Theme, now = Date.now()): string {
1908
1948
 
1909
1949
  /** Footer: the animation on the left, other extensions' statuses on the right. */
1910
1950
  function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1951
+ let scrolling = false;
1952
+ let scrollOff = 0;
1953
+ let scrollPeriod = 1;
1954
+ let scrollLine = "";
1911
1955
  const timer = setInterval(() => {
1912
1956
  if (animating()) tui.requestRender();
1913
1957
  }, ANIM_STEP_MS);
1958
+ // The marquee keeps its own cadence: one cell per tick, never derived from the clock, so it
1959
+ // does not stutter when the line is re-rendered at some other pace (animation, streamed tokens)
1960
+ // nor jump when the left side changes length (sephirah name, tok/s) and with it the loop period.
1961
+ const scrollTimer = setInterval(() => {
1962
+ if (!scrolling) return;
1963
+ scrollOff = (scrollOff + 1) % scrollPeriod;
1964
+ tui.requestRender();
1965
+ }, FOOTER_SCROLL_MS);
1914
1966
 
1915
1967
  const unsub = footerData?.onBranchChange?.(() => tui.requestRender());
1916
1968
 
@@ -1919,14 +1971,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1919
1971
  const dim = (s: string) => theme.fg("dim", s);
1920
1972
  const statuses = footerData?.getExtensionStatuses?.();
1921
1973
  const right = statuses ? [...statuses.values()].filter(Boolean).join(dim(" │ ")) : "";
1922
- const room = Math.max(12, width - visibleWidth(right) - 2);
1923
- const l = truncateToWidth(footerLeft(theme), room);
1924
- const gap = Math.max(1, width - visibleWidth(l) - visibleWidth(right));
1925
- return [truncateToWidth(l + " ".repeat(gap) + right, width)];
1974
+ const left = footerLeft(theme);
1975
+ const gap = width - visibleWidth(left) - visibleWidth(right);
1976
+ scrolling = gap < 1;
1977
+ if (!scrolling) {
1978
+ scrollLine = "";
1979
+ scrollOff = 0;
1980
+ return [left + " ".repeat(gap) + right];
1981
+ }
1982
+ // Too narrow to fit: scroll the whole line instead of cutting it off. The line is frozen
1983
+ // while it scrolls — live text changes width (seconds, tok/s, sephirah names) and every
1984
+ // change would shift it under the window. A fresh line is taken when it has the same
1985
+ // width, so nothing moves, otherwise at the end of the loop.
1986
+ const line = left + dim(FOOTER_SCROLL_GAP) + right + dim(FOOTER_SCROLL_GAP);
1987
+ const period = Math.max(1, visibleWidth(line));
1988
+ if (!scrollLine || scrollOff === 0 || period === scrollPeriod) {
1989
+ scrollLine = line;
1990
+ scrollPeriod = period;
1991
+ }
1992
+ return [sliceByColumn(scrollLine + scrollLine, scrollOff % scrollPeriod, width, true)];
1926
1993
  },
1927
1994
  invalidate() {},
1928
1995
  dispose() {
1929
1996
  clearInterval(timer);
1997
+ clearInterval(scrollTimer);
1930
1998
  unsub?.();
1931
1999
  },
1932
2000
  };
@@ -2120,6 +2188,7 @@ export default function (pi: ExtensionAPI) {
2120
2188
  ui.compact = cfg.ui?.compact ?? false;
2121
2189
  ui.kwhPrice = cfg.ui?.kwhPrice;
2122
2190
  ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
2191
+ swap.diskWh = cfg.totalWh ?? 0;
2123
2192
  if (ctx.mode !== "tui") return;
2124
2193
  applyChrome(ctx);
2125
2194
  // nothing is loaded at startup: a new session picks its MECHA unit, a resumed one shows whether its model is in VRAM
@@ -2173,6 +2242,7 @@ export default function (pi: ExtensionAPI) {
2173
2242
  });
2174
2243
 
2175
2244
  pi.on("session_shutdown", async () => {
2245
+ persistEnergy(true);
2176
2246
  prewarm.abort?.abort();
2177
2247
  clearInterval(metricsTimer);
2178
2248
  metricsTimer = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-magi-theme",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "MAGI SYSTEM theme + extension for pi (Evangelion fan art): MAGI control screen panel, three-model /magi council, MECHA SELECT model picker, angel-attack loading, seven-seal context gauge, llama-swap telemetry",
5
5
  "keywords": [
6
6
  "pi-package",