pi-magi-theme 0.2.2 → 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A three-mind council theme + extension for [pi](https://pi.dev): a MAGI SYSTEM header, the three MAGI as their control screen in a fixed side panel, live llama-swap telemetry, and `/magi`, a council of three models that votes on your engineering questions and reviews your pending changes.
4
4
 
5
- Fan-art theme inspired by Neon Genesis Evangelion: the MAGI and their screen belong to their creators, all rights reserved to khara, Inc. This project is not affiliated with them. The rest of the symbolism (the Tree of Life, the golem, the seven seals) is public domain.
5
+ Fan-art theme inspired by Neon Genesis Evangelion: the MAGI and their screen belong to their creators, all rights reserved to khara, Inc. This project is not affiliated with them. The rest of the symbolism (the Tree of Life, the sephirot, the seven seals) is public domain.
6
6
 
7
7
  ![pi with the MAGI theme: MAGI SYSTEM header, MECHA SELECT model picker, side panel with the MAGI screen and llama-swap telemetry](https://raw.githubusercontent.com/b-iurea/pi-magi-theme/main/docs/screenshot.png)
8
8
 
@@ -59,9 +59,9 @@ Every symbol stands for something real the agent is doing.
59
59
  | red spreading through BALTHASAR, MELCHIOR, CASPAR | an angel hacking the MAGI | the model is **being loaded into VRAM**, at the pace of its last load; CASPAR's last corner blinks once everything else has fallen | panel |
60
60
  | blue taking the MAGI back from that corner | the attack repelled | the model is **loaded** | panel |
61
61
  | MECHA-I · II · III · LEGION | units waiting for a pilot | the **llama-swap models**: dormant, waking while loading, eyes lit in VRAM | MECHA SELECT |
62
- | the golem, EMET ("truth") | a clay servant that acts | a **tool is running**, with the file or command it works on | panel + footer |
63
- | the golem, MET ("death") | the aleph is erased | a **tool failed** | panel + footer |
64
- | SYNC | the golem's obedience | **tool success rate** | panel + footer |
62
+ | the session's unit, in sync | the unit acts under its pilot | a **tool is running**, with the file or command it works on | panel + footer |
63
+ | the unit going berserk | the leash breaks: red eyes, jaw wide open | a **tool failed** | panel + footer |
64
+ | SYNC | the unit's sync ratio | **tool success rate** | panel + footer |
65
65
  | CHESED ✓ / GEBURAH ✗ | mercy / severity | **successful / failed tools** | panel + footer |
66
66
  | the seven seals | the end of an age | **context window usage**, one seal per seventh | panel |
67
67
  | the sixth seal blinking | the last warning | context **close to compaction**: compact now instead of mid-task | panel + footer |
@@ -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:
@@ -7,7 +7,7 @@
7
7
  * to Malkuth while it answers, ascending while the model is loaded into VRAM, at rest in Malkuth when idle
8
8
  * - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
9
9
  * and form the /magi council: three real models voting (on a question, or on the pending git diff)
10
- * - the GOLEM acts: it is animated by EMET ("truth") while tools run, showing the file or command it works on;
10
+ * - the session's MECHA unit acts while tools run, showing the file or command it works on, and goes berserk when one fails;
11
11
  * a failing tool erases the aleph and EMET becomes MET ("death"). SYNC is the tool success rate
12
12
  * - CHESED (mercy) and GEBURAH (severity) count successful and failed tools
13
13
  * - the SEVEN SEALS measure the context window; the sixth warns before compaction, the seventh opens when it runs
@@ -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
 
@@ -97,42 +97,10 @@ function renderPath(th: Theme, lights: NodeLight[]): string {
97
97
  return out;
98
98
  }
99
99
 
100
- /** The golem, 20×8 cells: at rest, striking (arms raised), and fallen (EMET → MET). */
101
- const GOLEM_REST = [
102
- " ▄██████▄ ",
103
- " █ EMET █ ",
104
- " ▀██████▀ ",
105
- " ▄████████████▄ ",
106
- " ██ ████████ ██ ",
107
- " ▀▀ ████████ ▀▀ ",
108
- " ███ ███ ",
109
- " ▀▀▀▀ ▀▀▀▀ ",
110
- ];
111
- const GOLEM_STRIKE = [
112
- " ▄▄ ▄██████▄ ▄▄ ",
113
- " ██ █ EMET █ ██ ",
114
- " ██ ▀██████▀ ██ ",
115
- " ▀████████████████▀ ",
116
- " ████████ ",
117
- " ████████ ",
118
- " ███ ███ ",
119
- " ▀▀▀▀ ▀▀▀▀ ",
120
- ];
121
- const GOLEM_FALLEN = [
122
- " ▄██████▄ ",
123
- " █ MET █ ",
124
- " ▀██████▀ ",
125
- " ▄████████████▄ ",
126
- " ██ ██░███░█ ██ ",
127
- " ▀▀ █░████░█ ▀▀ ",
128
- " ███ ███ ",
129
- " ▀▀▀▀ ▀▀▀▀ ",
130
- ];
131
-
132
100
  /* ── MECHA units: the heads of the model picker, one unit per model llama-swap runs ── */
133
101
 
134
102
  type MechaUnit = "I" | "II" | "III" | "LEGION";
135
- type MechaState = "dormant" | "waking" | "active";
103
+ type MechaState = "dormant" | "waking" | "active" | "berserk";
136
104
  type Rgb = readonly [number, number, number];
137
105
 
138
106
  /**
@@ -243,17 +211,31 @@ const MECHA: Record<MechaUnit, { name: string; armor: Rgb; accent: Rgb; eyes: Rg
243
211
  },
244
212
  };
245
213
 
214
+ /** Every head is drawn on the same 13×7 grid. */
215
+ const MECHA_W = 13;
216
+
246
217
  const rgb = ([r, g, b]: Rgb, s: string) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
247
218
  const shade = (c: Rgb, k: number): Rgb => [Math.round(c[0] * k), Math.round(c[1] * k), Math.round(c[2] * k)];
248
219
 
249
- /** One MECHA head: dormant (dark, eyes off), waking (eyes flicker, the jaw lock breaks), active (armor lit, eyes pulse). */
220
+ /** The berserk eyes: the unit off its leash sees in red, whatever colour its own eyes are. */
221
+ const BERSERK_EYES: Rgb = [255, 40, 40];
222
+
223
+ /**
224
+ * One MECHA head: dormant (dark, eyes off), waking (eyes flicker, the jaw lock breaks),
225
+ * active (armor lit, eyes pulse), berserk (jaw wide open, red eyes, a tool just failed).
226
+ */
250
227
  function renderMechaHead(unit: MechaUnit, state: MechaState, frame: number): string[] {
251
228
  const u = MECHA[unit];
252
- const jawOpen = state === "waking" && frame % 8 < 4;
253
- const eyesOn = state === "active" || (state === "waking" && flicker(frame, 7));
229
+ const jawOpen = state === "berserk" || (state === "waking" && frame % 8 < 4);
230
+ const eyesOn = state === "active" || state === "berserk" || (state === "waking" && flicker(frame, 7));
254
231
  const armor = state === "dormant" ? shade(u.armor, 0.3) : state === "waking" ? shade(u.armor, 0.7) : u.armor;
255
232
  const accent = state === "dormant" ? shade(u.accent, 0.3) : u.accent;
256
- const eyes = eyesOn ? shade(u.eyes, state === "active" ? 0.75 + 0.25 * Math.sin(frame / 3) : 1) : shade(u.eyes, 0.18);
233
+ const eyes =
234
+ state === "berserk"
235
+ ? shade(BERSERK_EYES, 0.7 + 0.3 * Math.sin(frame / 1.5)) // the roar throbs faster than the sync pulse
236
+ : eyesOn
237
+ ? shade(u.eyes, state === "active" ? 0.75 + 0.25 * Math.sin(frame / 3) : 1)
238
+ : shade(u.eyes, 0.18);
257
239
  return u.art.map((row, r) => {
258
240
  const jaw = jawOpen ? u.jaw.rows[r - u.jaw.at] : undefined;
259
241
  const art = jaw ?? row;
@@ -323,6 +305,7 @@ const state = {
323
305
  turns: 0,
324
306
  toolOk: 0, // CHESED
325
307
  toolFail: 0, // GEBURAH
308
+ unit: "I" as MechaUnit, // the session model's own unit: it is the one that acts while a tool runs
326
309
  lastFailAt: 0,
327
310
  lastFailTool: "",
328
311
  lastFailTarget: "",
@@ -357,12 +340,17 @@ function setPhase(p: Phase): void {
357
340
  }
358
341
 
359
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;
360
345
  const FAIL_FLASH_MS = 2500;
361
346
  const REBIRTH_MS = 6000;
362
347
  const DONE_TITLE_AFTER_MS = 30_000;
363
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 = " · ";
364
352
 
365
- /** Golem obedience: share of tool calls that succeeded (null before the first tool). */
353
+ /** Sync ratio: share of tool calls that succeeded (null before the first tool). */
366
354
  function syncPercent(): number | null {
367
355
  const done = state.toolOk + state.toolFail;
368
356
  return done ? (state.toolOk / done) * 100 : null;
@@ -727,6 +715,8 @@ const swap = {
727
715
  cacheTokens: 0,
728
716
  inputTokens: 0,
729
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
730
720
  lastSampleAt: 0,
731
721
  lastWatts: 0,
732
722
  };
@@ -777,6 +767,34 @@ function sampleEnergy(now = Date.now()): void {
777
767
  }
778
768
  swap.lastSampleAt = now;
779
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;
780
798
  }
781
799
 
782
800
  async function refreshSwapMetrics(): Promise<void> {
@@ -1147,23 +1165,18 @@ class MagiPanel implements Component {
1147
1165
  return this.frameLine(" " + l + this.theme.fg(tone, value), inner);
1148
1166
  }
1149
1167
 
1150
- /* ── the golem: tools ── */
1168
+ /* ── the unit at work: tools ── */
1151
1169
 
1152
- private golemArt(inner: number): string[] {
1153
- const th = this.theme;
1154
- const fallen = Date.now() - state.lastFailAt < FAIL_FLASH_MS;
1155
- const art = fallen ? GOLEM_FALLEN : this.frame % 4 < 2 ? GOLEM_STRIKE : GOLEM_REST;
1156
- const tone: ThemeColor = fallen ? "error" : "accent";
1157
- const left = " ".repeat(Math.max(0, Math.floor((inner - 20) / 2)));
1158
- return art.map((line) => {
1159
- const word = fallen ? "MET" : "EMET";
1160
- const at = line.indexOf(word);
1161
- if (at < 0) return left + th.fg(tone, line);
1162
- return left + th.fg(tone, line.slice(0, at)) + th.bold(th.fg(fallen ? "error" : "warning", word)) + th.fg(tone, line.slice(at + word.length));
1163
- });
1170
+ /** The session's own unit while a tool runs; berserk (and shaking) for as long as the failure flashes. */
1171
+ private mechaArt(inner: number): string[] {
1172
+ const berserk = Date.now() - state.lastFailAt < FAIL_FLASH_MS;
1173
+ const art = renderMechaHead(state.unit, berserk ? "berserk" : "active", this.frame);
1174
+ const shake = berserk ? (this.frame % 2 ? 1 : -1) : 0;
1175
+ const left = " ".repeat(Math.max(0, Math.floor((inner - MECHA_W) / 2) + shake));
1176
+ return art.map((line) => left + line);
1164
1177
  }
1165
1178
 
1166
- /* ── top of the panel: the MAGI (flickering while working) or the golem (tools) ── */
1179
+ /* ── top of the panel: the MAGI (flickering while working) or the unit (tools) ── */
1167
1180
 
1168
1181
  private councilSection(inner: number): string[] {
1169
1182
  const th = this.theme;
@@ -1180,9 +1193,10 @@ class MagiPanel implements Component {
1180
1193
  let status: string;
1181
1194
 
1182
1195
  if (state.phase === "tool" || (idle && fallen)) {
1183
- title = fallen ? `GOLEM FELL · MET · ${state.lastFailTool}` : `GOLEM · ${state.toolName || "tool"} · ${secs(state.phaseSince)}`;
1196
+ const name = MECHA[state.unit].name;
1197
+ title = fallen ? `${name} BERSERK · ${state.lastFailTool}` : `${name} · ${state.toolName || "tool"} · ${secs(state.phaseSince)}`;
1184
1198
  titleTone = fallen ? "error" : "warning";
1185
- body = this.golemArt(inner);
1199
+ body = this.mechaArt(inner);
1186
1200
  const target = (fallen ? state.lastFailTarget || state.lastFailTool : state.toolTarget || state.toolName) || "tool";
1187
1201
  status = `${pulse} ${th.fg(fallen ? "error" : "text", truncateToWidth(target, inner - 4))}`;
1188
1202
  } else {
@@ -1275,7 +1289,7 @@ class MagiPanel implements Component {
1275
1289
  if (tokens.cost) out.push(this.field("API COST", `$${tokens.cost.toFixed(3)}`, inner, "muted"));
1276
1290
  }
1277
1291
 
1278
- // SYNC: the golem's obedience = tool success rate (CHESED ✓ / GEBURAH ✗)
1292
+ // SYNC: the unit's sync ratio = tool success rate (CHESED ✓ / GEBURAH ✗)
1279
1293
  const sync = syncPercent();
1280
1294
  out.push(
1281
1295
  this.frameLine(
@@ -1332,11 +1346,14 @@ class MagiPanel implements Component {
1332
1346
  return out;
1333
1347
  }
1334
1348
 
1335
- /** 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. */
1336
1350
  private costRow(inner: number): string {
1337
1351
  if (!swap.gpus.length) return this.field("COST", "—", inner, "muted");
1338
1352
  if (ui.kwhPrice === undefined) return this.field("COST", "→ /magi-ui config", inner, "dim");
1339
- 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);
1340
1357
  }
1341
1358
 
1342
1359
  private swapRows(inner: number, compact: boolean): string[] {
@@ -1378,7 +1395,7 @@ class MagiPanel implements Component {
1378
1395
  );
1379
1396
  }
1380
1397
  if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
1381
- 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"));
1382
1399
  out.push(this.costRow(inner));
1383
1400
  return out;
1384
1401
  }
@@ -1487,6 +1504,7 @@ interface MagiUnitConfig {
1487
1504
  type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & {
1488
1505
  ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency };
1489
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
1490
1508
  };
1491
1509
 
1492
1510
  const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
@@ -1818,7 +1836,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1818
1836
  /**
1819
1837
  * The footer's left side, one animation per real state of the agent:
1820
1838
  * - seals breaking / seventh seal opened → context compaction
1821
- * - golem EMET / MET → tool running (with its file or command) / tool failed
1839
+ * - unit in sync / berserk → tool running (with its file or command) / tool failed
1822
1840
  * - light pulsing in the upper triad → thinking
1823
1841
  * - light descending to Malkuth → streaming the answer
1824
1842
  * - light ascending from Malkuth → loading the model into VRAM
@@ -1828,6 +1846,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1828
1846
  function footerLeft(th: Theme, now = Date.now()): string {
1829
1847
  const dim = (s: string) => th.fg("dim", s);
1830
1848
  const step = Math.floor(now / ANIM_STEP_MS);
1849
+ const slowStep = Math.floor(now / SEPHIRAH_STEP_MS);
1831
1850
  const lights = (fn: (i: number) => NodeLight) => SEPHIROT.map((_, i) => fn(i));
1832
1851
 
1833
1852
  if (state.compacting) {
@@ -1856,13 +1875,22 @@ function footerLeft(th: Theme, now = Date.now()): string {
1856
1875
  const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
1857
1876
  if (now - state.lastFailAt < FAIL_FLASH_MS) {
1858
1877
  const target = state.lastFailTarget ? dim(" ") + th.fg("error", truncateToWidth(state.lastFailTarget, 40)) : "";
1859
- return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + target + dim(" ·") + tally + syncText;
1878
+ return (
1879
+ th.fg("error", `✗ ${MECHA[state.unit].name} · `) +
1880
+ th.bold(th.fg("error", "BERSERK")) +
1881
+ dim(" · ") +
1882
+ th.fg("error", `${state.lastFailTool} failed`) +
1883
+ target +
1884
+ dim(" ·") +
1885
+ tally +
1886
+ syncText
1887
+ );
1860
1888
  }
1861
- const hammer = ["▚", "▞"][step % 2]!;
1889
+ const roar = ["▚", "▞"][step % 2]!;
1862
1890
  const target = state.toolTarget ? dim(" ") + th.fg("muted", truncateToWidth(state.toolTarget, 40)) : "";
1863
1891
  return (
1864
- th.fg("accent", `${hammer} GOLEM · `) +
1865
- th.bold(th.fg("warning", "EMET")) +
1892
+ th.fg("accent", `${roar} ${MECHA[state.unit].name} · `) +
1893
+ th.bold(th.fg("warning", "SYNC")) +
1866
1894
  dim(" · ") +
1867
1895
  th.fg("text", state.toolName || "tool") +
1868
1896
  target +
@@ -1872,7 +1900,7 @@ function footerLeft(th: Theme, now = Date.now()): string {
1872
1900
  );
1873
1901
  }
1874
1902
  if (state.phase === "thinking") {
1875
- const cur = Math.floor(step / 2) % 3; // Keter, Chokmah, Binah
1903
+ const cur = slowStep % 3; // Keter, Chokmah, Binah
1876
1904
  const s = SEPHIROT[cur]!;
1877
1905
  return (
1878
1906
  th.fg("accent", "◆ ") +
@@ -1882,7 +1910,7 @@ function footerLeft(th: Theme, now = Date.now()): string {
1882
1910
  );
1883
1911
  }
1884
1912
  if (state.phase === "responding") {
1885
- const cur = 5 + (step % 5); // Tiferet → Malkuth
1913
+ const cur = 5 + (slowStep % 5); // Tiferet → Malkuth
1886
1914
  const s = SEPHIROT[cur]!;
1887
1915
  const tps = liveTps();
1888
1916
  return (
@@ -1920,9 +1948,21 @@ function footerLeft(th: Theme, now = Date.now()): string {
1920
1948
 
1921
1949
  /** Footer: the animation on the left, other extensions' statuses on the right. */
1922
1950
  function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1951
+ let scrolling = false;
1952
+ let scrollOff = 0;
1953
+ let scrollPeriod = 1;
1954
+ let scrollLine = "";
1923
1955
  const timer = setInterval(() => {
1924
1956
  if (animating()) tui.requestRender();
1925
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);
1926
1966
 
1927
1967
  const unsub = footerData?.onBranchChange?.(() => tui.requestRender());
1928
1968
 
@@ -1931,14 +1971,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1931
1971
  const dim = (s: string) => theme.fg("dim", s);
1932
1972
  const statuses = footerData?.getExtensionStatuses?.();
1933
1973
  const right = statuses ? [...statuses.values()].filter(Boolean).join(dim(" │ ")) : "";
1934
- const room = Math.max(12, width - visibleWidth(right) - 2);
1935
- const l = truncateToWidth(footerLeft(theme), room);
1936
- const gap = Math.max(1, width - visibleWidth(l) - visibleWidth(right));
1937
- 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)];
1938
1993
  },
1939
1994
  invalidate() {},
1940
1995
  dispose() {
1941
1996
  clearInterval(timer);
1997
+ clearInterval(scrollTimer);
1942
1998
  unsub?.();
1943
1999
  },
1944
2000
  };
@@ -2073,7 +2129,7 @@ export default function (pi: ExtensionAPI) {
2073
2129
  let lastPoll = 0;
2074
2130
  let titleTimer: ReturnType<typeof setInterval> | undefined;
2075
2131
  let liveTimer: ReturnType<typeof setInterval> | undefined;
2076
- /** Tools currently executing, by call id: the golem shows one of them, and leaves when none is left. */
2132
+ /** Tools currently executing, by call id: the unit shows one of them, and leaves when none is left. */
2077
2133
  const runningTools = new Map<string, { name: string; target: string }>();
2078
2134
 
2079
2135
  /** The picker has the keyboard: keys pressed there must not wake the default model. */
@@ -2112,14 +2168,27 @@ export default function (pi: ExtensionAPI) {
2112
2168
  }
2113
2169
  };
2114
2170
 
2171
+ /** Which unit acts in the panel: the one the picker gave this session's model. LEGION for anything not llama-swap. */
2172
+ const refreshUnit = async (ctx: ExtensionContext, model: Model<any> | undefined = ctx.model) => {
2173
+ if (!model) return;
2174
+ try {
2175
+ const aliases = await swapAliases();
2176
+ state.unit = mechaUnitsOf(ctx, aliases).get(aliases.get(model.id) ?? model.id) ?? "LEGION";
2177
+ } catch {
2178
+ state.unit = "LEGION"; // llama-swap unreachable: no roster, so the unit is nameless
2179
+ }
2180
+ };
2181
+
2115
2182
  pi.on("session_start", async (event, ctx) => {
2116
2183
  liveCtx = ctx;
2184
+ void refreshUnit(ctx);
2117
2185
  recountSession(ctx);
2118
2186
  state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
2119
2187
  const cfg = loadMagiConfig();
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
@@ -2168,10 +2237,12 @@ export default function (pi: ExtensionAPI) {
2168
2237
 
2169
2238
  pi.on("model_select", async (event, ctx) => {
2170
2239
  liveCtx = ctx;
2240
+ void refreshUnit(ctx, event.model);
2171
2241
  if (ctx.mode === "tui") void preloadModel(ctx, event.model);
2172
2242
  });
2173
2243
 
2174
2244
  pi.on("session_shutdown", async () => {
2245
+ persistEnergy(true);
2175
2246
  prewarm.abort?.abort();
2176
2247
  clearInterval(metricsTimer);
2177
2248
  metricsTimer = undefined;
@@ -2212,7 +2283,7 @@ export default function (pi: ExtensionAPI) {
2212
2283
  if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
2213
2284
  else if (t === "text_start" || t === "text_delta") setPhase("responding");
2214
2285
  else if (t === "toolcall_start" || t === "toolcall_delta") {
2215
- // the model is writing a tool call: the golem shows which tool it prepares, not the previous one
2286
+ // the model is writing a tool call: the unit shows which tool it prepares, not the previous one
2216
2287
  setPhase("tool");
2217
2288
  const call = e.partial?.content?.[e.contentIndex];
2218
2289
  if (call?.type === "toolCall") {
@@ -2259,7 +2330,7 @@ export default function (pi: ExtensionAPI) {
2259
2330
  repaint();
2260
2331
  });
2261
2332
 
2262
- // CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
2333
+ // CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure throws the unit berserk.
2263
2334
  pi.on("tool_execution_end", async (event) => {
2264
2335
  const tool = runningTools.get(event.toolCallId);
2265
2336
  runningTools.delete(event.toolCallId);
@@ -2273,7 +2344,7 @@ export default function (pi: ExtensionAPI) {
2273
2344
  }
2274
2345
  const next = [...runningTools.values()][0];
2275
2346
  if (next) {
2276
- // parallel tools: the golem moves on to one still running
2347
+ // parallel tools: the unit moves on to one still running
2277
2348
  state.toolName = next.name;
2278
2349
  state.toolTarget = next.target;
2279
2350
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-magi-theme",
3
- "version": "0.2.2",
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",