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 +20 -6
- package/extensions/magi/index.ts +146 -75
- package/package.json +1 -1
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
|
|
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
|

|
|
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
|
|
63
|
-
| the
|
|
64
|
-
| SYNC | the
|
|
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
|
|
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:
|
package/extensions/magi/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
|
1168
|
+
/* ── the unit at work: tools ── */
|
|
1151
1169
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1155
|
-
const art =
|
|
1156
|
-
const
|
|
1157
|
-
const left = " ".repeat(Math.max(0, Math.floor((inner -
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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", `${(
|
|
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
|
-
* -
|
|
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
|
|
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
|
|
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", `${
|
|
1865
|
-
th.bold(th.fg("warning", "
|
|
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 =
|
|
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 + (
|
|
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
|
|
1935
|
-
const
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|