pi-magi-theme 0.1.4 → 0.2.1

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
@@ -1,12 +1,12 @@
1
1
  # pi-magi-theme
2
2
 
3
- A three-mind council theme + extension for [pi](https://pi.dev): a detailed Tree of Life in the header, the three MAGI 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.
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
- All artwork is original. The symbolism (the three Magi, 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 golem, the seven seals) is public domain.
6
6
 
7
- ![pi with the MAGI theme: Tree of Life header, side panel with the MAGI triangle and llama-swap telemetry](https://raw.githubusercontent.com/b-iurea/pi-magi-theme/main/docs/screenshot.png)
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
 
9
- ![The MAGI deliberating while the model thinks](https://raw.githubusercontent.com/b-iurea/pi-magi-theme/main/docs/deliberation.png)
9
+ ![The angel attack: red spreads through the MAGI while the model loads into VRAM](https://raw.githubusercontent.com/b-iurea/pi-magi-theme/main/docs/angel-attack.png)
10
10
 
11
11
  ## Install
12
12
 
@@ -39,6 +39,7 @@ Clone the repo and point pi at it instead (edits in the repo are live on the nex
39
39
  | `/magi <question>` | the council answers a question (recent conversation as context) |
40
40
  | `/magi review [focus]` | the council reviews your pending changes (`git diff HEAD` plus untracked file names) before you commit |
41
41
  | `/magi config` | pick a model for each MAGI |
42
+ | `/magi mecha` | MECHA SELECT: pick the llama-swap model to activate, each shown as a mecha head lit by its real state |
42
43
  | `/magi-ui compact` | toggle the compact side panel (basic info and animations only); remembered across sessions |
43
44
  | `/magi-ui status` | llama-swap report from its last 100 requests: speed, tokens, cache hits, MTP draft acceptance, durations, errors per model |
44
45
  | `/magi-ui config` | set the electricity price per kWh and the currency (EUR or USD) for the COST row |
@@ -54,7 +55,10 @@ Every symbol stands for something real the agent is doing.
54
55
  | light descending Tiferet → Malkuth | manifestation | the model is **streaming the answer** (with live tok/s) | footer |
55
56
  | light ascending Malkuth → Keter | ascent | the model is **being loaded into VRAM** | footer |
56
57
  | Malkuth at rest | the kingdom | **idle**, with the last run duration | footer |
57
- | MELCHIOR · BALTHASAR · CASPAR flickering | the three Magi at work | what the agent **is doing** (thinking, responding, loading, compacting); below them, the **last real `/magi` verdict** | panel |
58
+ | MELCHIOR · BALTHASAR · CASPAR flickering | the three Magi at work | what the agent **is doing** (thinking, responding, compacting); below them, the **last real `/magi` verdict** | panel |
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
+ | blue taking the MAGI back from that corner | the attack repelled | the model is **loaded** | panel |
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 |
58
62
  | the golem, EMET ("truth") | a clay servant that acts | a **tool is running**, with the file or command it works on | panel + footer |
59
63
  | the golem, MET ("death") | the aleph is erased | a **tool failed** | panel + footer |
60
64
  | SYNC | the golem's obedience | **tool success rate** | panel + footer |
@@ -99,19 +103,24 @@ Each nature is a lens, not a specialty, so the council answers any question, not
99
103
  ```json
100
104
  {
101
105
  "MELCHIOR": { "model": "llama-swap/Qwen3.8 27B Q4_K_M - Thinking", "thinking": "low" },
102
- "ui": { "compact": false, "kwhPrice": 0.30, "currency": "EUR" }
106
+ "ui": { "compact": false, "kwhPrice": 0.30, "currency": "EUR" },
107
+ "loads": { "qwen3.8-27b": 41200 }
103
108
  }
104
109
  ```
105
110
 
106
111
  - per MAGI: `model` (unset = current session model) and optional `thinking` level;
107
112
  - `ui.compact`: start with the compact side panel;
108
- - `ui.kwhPrice` and `ui.currency` (`EUR` or `USD`): the COST row multiplies the GPU energy used in the session by this price.
113
+ - `ui.kwhPrice` and `ui.currency` (`EUR` or `USD`): the COST row multiplies the GPU energy used in the session by this price;
114
+ - `loads`: written by the theme, how long each llama-swap model took to load last time (paces the angel attack; 60s when unknown).
109
115
 
110
116
  ## llama-swap
111
117
 
112
118
  When the session model uses the `llama-swap` provider, the side panel:
113
119
 
114
- - loads the model into VRAM on startup and on model change (`GET /upstream/<model>/health`), with a MAGI boot animation;
120
+ - loads nothing at startup: a new session opens MECHA SELECT, a resumed one shows whether its model is already in VRAM;
121
+ - loads the model into VRAM when you pick it, change it with `/model` or type (`GET /upstream/<model>/health`), with the angel attack animation;
122
+ - prewarms a new session: pi's real system prompt and tools are processed as soon as the model is ready, so the first answer doesn't wait for them;
123
+ - follows the context live in the seven seals while the model works (`/upstream/<model>/slots`);
115
124
  - notices when llama-swap unloads the model (`/running`), shows it asleep and reloads it as soon as you type;
116
125
  - shows VRAM, GPU load/temperature/power, RAM and the GPU energy used in the session from `/metrics` (every 3s while working, every 30s when idle);
117
126
  - shows server-measured tok/s, prompt tok/s and KV cache hits of the last request (`/api/metrics/activity?limit=1`).
@@ -2,7 +2,8 @@
2
2
  * MAGI — a three-mind council chrome for pi
3
3
  *
4
4
  * One public-domain mythology, every symbol bound to a real function:
5
- * - the TREE OF LIFE shows where the agent is: the upper triad while it thinks, the light descending
5
+ * - the panel draws the three MAGI as the panels of their control screen
6
+ * - the sephirot of the Tree of Life show where the agent is: the upper triad while it thinks, the light descending
6
7
  * to Malkuth while it answers, ascending while the model is loaded into VRAM, at rest in Malkuth when idle
7
8
  * - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
8
9
  * and form the /magi council: three real models voting (on a question, or on the pending git diff)
@@ -10,10 +11,15 @@
10
11
  * a failing tool erases the aleph and EMET becomes MET ("death"). SYNC is the tool success rate
11
12
  * - CHESED (mercy) and GEBURAH (severity) count successful and failed tools
12
13
  * - the SEVEN SEALS measure the context window; the sixth warns before compaction, the seventh opens when it runs
14
+ * - MECHA SELECT: a new llama-swap session loads nothing until you pick the unit (model) to activate;
15
+ * each model gets a MECHA head, lit when it is already in VRAM, waking while it loads;
16
+ * /magi mecha reopens it
17
+ * - while a model loads an angel attacks the MAGI: red spreads through BALTHASAR, MELCHIOR and CASPAR at the pace of
18
+ * the model's last load, a corner of CASPAR holds out blinking; once loaded, blue takes the MAGI back from that corner
13
19
  * - llama-swap telemetry: VRAM, GPU load/temp/power, energy used, RAM, server-side tok/s, prompt tok/s, cache hits
14
20
  * - /magi config → assign a model to each MAGI; /magi-ui compact|status → smaller panel, llama-swap report
15
21
  *
16
- * Artwork is original; the symbolism is public domain.
22
+ * Fan art: the MAGI and their screen come from Neon Genesis Evangelion, all rights reserved to khara, Inc.
17
23
  * Use with the theme ../../themes/magi.json
18
24
  */
19
25
 
@@ -39,9 +45,30 @@ const MAGI_WORD = [
39
45
  "╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝",
40
46
  ];
41
47
 
42
- /** Tree of Life canvas, in terminal cells; drawn with braille dots (2×4 sub-pixels per cell). */
43
- const TREE_W = 30;
44
- const TREE_H = 20;
48
+ /** The seven-eyed seal, 26×11 cells of braille, traced from the original emblem. */
49
+ const SEELE_LOGO = [
50
+ " ⠹⡍⠉⠉⠉⠉⠉⠉⠉⠉⠉⢹⠉⠉⠉⠉⠉⠉⠉⣉⡉⠉⢩⠇",
51
+ "⣠⠖⠛⢩⠋⠉⢯⠙⠲⢤⡀ ⢸ ⢠⣴⠚⠉⡝⠉⠙⡍⠙⢲⣤",
52
+ " ⠙⠒⠾⡷⠴⠿⠖⠚⠁ ⢸ ⠈⠙⠒⠻⠶⣾⠗⠚⠉",
53
+ " ⢀⣠⣤⣼⢦⣤⣤⣀ ⢸ ⢀⣀⡤⢤⠴⢧⠤⣄⣀",
54
+ "⠺⢭⣀⣸⡁ ⣽⣀⣨⠽⠂ ⢸ ⠐⠫⢤⣀⣯⡀⣠⣇⣀⡬⠓",
55
+ " ⠉⠉⠉⠙⣇ ⢸ ⡼⠉⠉⠉⠉",
56
+ "⣠⠖⠚⢩⠏⠉⢯⠙⠓⢦⡀ ⢸ ⢀⡴⠚⠋⡽⠉⠙⡍⠓⠲⣄",
57
+ " ⠙⠒⠾⠷⠴⠿⠖⠚⢧ ⢸ ⢀⡼⠛⠒⠿⠦⠾⠗⠚⠉",
58
+ " ⠈⢧ ⢸ ⢀⡞ ⣀⣤⢤⡤⣤⣤⣄⡀",
59
+ " ⠈⢧⢸⢀⡞⠐⠯⣅⣀⣯ ⢈⣇⣀⡭⠗",
60
+ " ⢻⡟ ⠈⠉⠉⠉⠉",
61
+ ];
62
+ const SEELE_WIDTH = 26;
63
+
64
+ const SYSTEM_WORD = [
65
+ "███████╗██╗ ██╗███████╗████████╗███████╗███╗ ███╗",
66
+ "██╔════╝╚██╗ ██╔╝██╔════╝╚══██╔══╝██╔════╝████╗ ████║",
67
+ "███████╗ ╚████╔╝ ███████╗ ██║ █████╗ ██╔████╔██║",
68
+ "╚════██║ ╚██╔╝ ╚════██║ ██║ ██╔══╝ ██║╚██╔╝██║",
69
+ "███████║ ██║ ███████║ ██║ ███████╗██║ ╚═╝ ██║",
70
+ "╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝",
71
+ ];
45
72
 
46
73
  /** Sephirot in the order of the "lightning flash" (Keter → Malkuth), positioned in sub-pixels. */
47
74
  const SEPHIROT = [
@@ -57,82 +84,6 @@ const SEPHIROT = [
57
84
  { name: "MALKUTH", meaning: "the kingdom", x: 30, y: 75 },
58
85
  ] as const;
59
86
 
60
- /** Da'at, the hidden sephirah: drawn dashed, not part of the flash. */
61
- const DAAT = { x: 30, y: 28 };
62
-
63
- /** The 22 paths, as pairs of SEPHIROT indices. */
64
- const TREE_PATHS: readonly [number, number][] = [
65
- [0, 1], [0, 2], [0, 5], [1, 2], [1, 5], [1, 3], [2, 5], [2, 4], [3, 4], [3, 5], [3, 6],
66
- [4, 5], [4, 7], [5, 6], [5, 8], [5, 7], [6, 7], [6, 8], [6, 9], [7, 8], [7, 9], [8, 9],
67
- ];
68
-
69
- const BRAILLE_BITS = [
70
- [0x01, 0x08],
71
- [0x02, 0x10],
72
- [0x04, 0x20],
73
- [0x40, 0x80],
74
- ]; // [row][col] → dot bit
75
-
76
- /** Rasterizes the Tree of Life into braille: paths (muted), Da'at (dim, dashed), sephirot (accent rings). */
77
- function renderTreeOfLife(th: Theme): string[] {
78
- const W = TREE_W * 2;
79
- const H = TREE_H * 4;
80
- const PATH = 1;
81
- const DAAT_PX = 2;
82
- const NODE = 3;
83
- const px = new Uint8Array(W * H);
84
- const set = (x: number, y: number, v: number) => {
85
- const rx = Math.round(x);
86
- const ry = Math.round(y);
87
- if (rx >= 0 && ry >= 0 && rx < W && ry < H) px[ry * W + rx] = v;
88
- };
89
- for (const [a, b] of TREE_PATHS) {
90
- const p = SEPHIROT[a]!;
91
- const q = SEPHIROT[b]!;
92
- const n = Math.ceil(Math.max(Math.abs(q.x - p.x), Math.abs(q.y - p.y)));
93
- for (let i = 0; i <= n; i++) set(p.x + ((q.x - p.x) * i) / n, p.y + ((q.y - p.y) * i) / n, PATH);
94
- }
95
- // ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
96
- // dashed rings keep every other 30° arc
97
- const ring = (c: { x: number; y: number }, r: number, v: number, dashed: boolean) => {
98
- for (let y = -r - 2; y <= r + 2; y++) {
99
- for (let x = -r - 2; x <= r + 2; x++) {
100
- const d = Math.hypot(x, y);
101
- if (d > r + 1.5) continue;
102
- const onRing = d > r - 0.7 && d <= r + 0.5;
103
- const arc = Math.floor((Math.atan2(y, x) + Math.PI) / (Math.PI / 6)) % 2 === 0;
104
- set(c.x + x, c.y + y, onRing && (!dashed || arc) ? v : 0);
105
- }
106
- }
107
- };
108
- ring(DAAT, 3, DAAT_PX, true);
109
- for (const s of SEPHIROT) {
110
- ring(s, 3.5, NODE, false);
111
- set(s.x, s.y, NODE);
112
- }
113
-
114
- const lines: string[] = [];
115
- for (let row = 0; row < TREE_H; row++) {
116
- let line = "";
117
- for (let col = 0; col < TREE_W; col++) {
118
- let bits = 0;
119
- let top = 0;
120
- for (let dy = 0; dy < 4; dy++) {
121
- for (let dx = 0; dx < 2; dx++) {
122
- const v = px[(row * 4 + dy) * W + col * 2 + dx]!;
123
- if (!v) continue;
124
- bits |= BRAILLE_BITS[dy]![dx]!;
125
- top = Math.max(top, v);
126
- }
127
- }
128
- const ch = bits ? String.fromCharCode(0x2800 + bits) : " ";
129
- line += bits ? th.fg(top === NODE ? "accent" : top === DAAT_PX ? "dim" : "muted", ch) : ch;
130
- }
131
- lines.push(line);
132
- }
133
- return lines;
134
- }
135
-
136
87
  type NodeLight = "off" | "on" | "hot";
137
88
 
138
89
  /** The ten sephirot as a tiny path: ●━●━◉─○ … (hot = where the light is now). */
@@ -178,6 +129,167 @@ const GOLEM_FALLEN = [
178
129
  " ▀▀▀▀ ▀▀▀▀ ",
179
130
  ];
180
131
 
132
+ /* ── MECHA units: the heads of the model picker, one unit per model llama-swap runs ── */
133
+
134
+ type MechaUnit = "I" | "II" | "III" | "LEGION";
135
+ type MechaState = "dormant" | "waking" | "active";
136
+ type Rgb = readonly [number, number, number];
137
+
138
+ /**
139
+ * Heads of 13×7 cells. mask: e = eyes (the light), a = accent, anything else = armor.
140
+ * jaw: rows that replace the art from row `at` while the unit wakes (the jaw lock breaks).
141
+ * Colors are the units' own, outside the theme palette on purpose.
142
+ */
143
+ const MECHA: Record<MechaUnit, { name: string; armor: Rgb; accent: Rgb; eyes: Rgb; art: string[]; mask: string[]; jaw: { at: number; rows: string[] } }> = {
144
+ "I": {
145
+ name: "MECHA-I",
146
+ armor: [124, 82, 196],
147
+ accent: [132, 222, 84],
148
+ eyes: [170, 255, 120],
149
+ art: [
150
+ " ▲ ",
151
+ " ███ ",
152
+ " ▄█████▄ ",
153
+ " ██▀▀█▀▀██ ",
154
+ " █ ◣ ◢ █ ",
155
+ " ▀█▄▄█▄▄█▀ ",
156
+ " ▀▄▄▄▀ ",
157
+ ],
158
+ mask: [
159
+ " ",
160
+ " ",
161
+ " ",
162
+ " aa aa ",
163
+ " e e ",
164
+ " ",
165
+ " aaaaa ",
166
+ ],
167
+ jaw: { at: 5, rows: [" ▀█▀▀▀▀▀█▀ ", " █▄▀▄▀▄█ "] },
168
+ },
169
+ "II": {
170
+ name: "MECHA-II",
171
+ armor: [236, 140, 44],
172
+ accent: [90, 160, 255],
173
+ eyes: [120, 200, 255],
174
+ art: [
175
+ " ▄▄▄▄▄ ",
176
+ " ▄█▀▀▀▀▀█▄ ",
177
+ " ▐█ ▄▄▄ █▌ ",
178
+ " ▐█ ███ █▌ ",
179
+ " ▀█▄▄▄▄▄█▀ ",
180
+ " ▀███▀ ",
181
+ " ",
182
+ ],
183
+ mask: [
184
+ " aaaaa ",
185
+ " ",
186
+ " eee ",
187
+ " eee ",
188
+ " ",
189
+ " ",
190
+ " ",
191
+ ],
192
+ jaw: { at: 5, rows: [" ▀▄▀▄▀ "] },
193
+ },
194
+ "III": {
195
+ name: "MECHA-III",
196
+ armor: [214, 40, 44],
197
+ accent: [245, 160, 40],
198
+ eyes: [120, 255, 160],
199
+ art: [
200
+ " ◥▄ ▄◤ ",
201
+ " ██▄▄▄▄▄██ ",
202
+ " █ ● ● █ ",
203
+ " █ ● ● █ ",
204
+ " ▀██▄▄▄██▀ ",
205
+ " ▀███▀ ",
206
+ " ",
207
+ ],
208
+ mask: [
209
+ " aa aa ",
210
+ " ",
211
+ " e e ",
212
+ " e e ",
213
+ " ",
214
+ " ",
215
+ " ",
216
+ ],
217
+ jaw: { at: 4, rows: [" ▀█▀▄▀▄▀█▀ ", " ▀▄▀▄▀▄▀ "] },
218
+ },
219
+ LEGION: {
220
+ name: "LEGION",
221
+ armor: [228, 228, 232],
222
+ accent: [205, 40, 64],
223
+ eyes: [255, 90, 110],
224
+ art: [
225
+ " ▄▄▄ ",
226
+ " ▄█████▄ ",
227
+ " ▐███████▌ ",
228
+ " ▐█▀▄▀▄▀█▌ ",
229
+ " ▀▄▀▄▀▄▀ ",
230
+ " ",
231
+ " ",
232
+ ],
233
+ mask: [
234
+ " ",
235
+ " ",
236
+ " ",
237
+ " eeeee ",
238
+ " eeeeeee ",
239
+ " ",
240
+ " ",
241
+ ],
242
+ jaw: { at: 3, rows: [" ▐█▄▀▄▀▄█▌ ", " ▄▀▄▀▄▀▄ "] },
243
+ },
244
+ };
245
+
246
+ const rgb = ([r, g, b]: Rgb, s: string) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
247
+ const shade = (c: Rgb, k: number): Rgb => [Math.round(c[0] * k), Math.round(c[1] * k), Math.round(c[2] * k)];
248
+
249
+ /** One MECHA head: dormant (dark, eyes off), waking (eyes flicker, the jaw lock breaks), active (armor lit, eyes pulse). */
250
+ function renderMechaHead(unit: MechaUnit, state: MechaState, frame: number): string[] {
251
+ const u = MECHA[unit];
252
+ const jawOpen = state === "waking" && frame % 8 < 4;
253
+ const eyesOn = state === "active" || (state === "waking" && flicker(frame, 7));
254
+ const armor = state === "dormant" ? shade(u.armor, 0.3) : state === "waking" ? shade(u.armor, 0.7) : u.armor;
255
+ 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);
257
+ return u.art.map((row, r) => {
258
+ const jaw = jawOpen ? u.jaw.rows[r - u.jaw.at] : undefined;
259
+ const art = jaw ?? row;
260
+ let out = "";
261
+ for (let c = 0; c < art.length; c++) {
262
+ const ch = art[c]!;
263
+ const m = u.mask[r]![c];
264
+ out += ch === " " ? " " : rgb(m === "e" ? eyes : m === "a" ? accent : armor, ch);
265
+ }
266
+ return out;
267
+ });
268
+ }
269
+
270
+ /** MECHA-I goes to the default model, II and III to the next real models, the rest is the LEGION. */
271
+ function assignMechaUnits(realIds: string[], defaultRealId: string): Map<string, MechaUnit> {
272
+ const order = [...new Set(realIds.includes(defaultRealId) ? [defaultRealId, ...realIds] : realIds)];
273
+ const units: MechaUnit[] = ["I", "II", "III"];
274
+ return new Map(order.map((id, i) => [id, units[i] ?? "LEGION"]));
275
+ }
276
+
277
+ /**
278
+ * The unit of every llama-swap model, stable across /model switches: MECHA-I is settings.json's defaultModel.
279
+ * ponytail: reads only the global settings, a project .pi/settings.json override is ignored.
280
+ */
281
+ function mechaUnitsOf(ctx: ExtensionContext, aliases: Map<string, string>): Map<string, MechaUnit> {
282
+ let def = ctx.model?.id ?? "";
283
+ try {
284
+ def = JSON.parse(readFileSync(join(homedir(), ".pi", "agent", "settings.json"), "utf8")).defaultModel || def;
285
+ } catch {
286
+ // no settings: the session model leads
287
+ }
288
+ const realOf = (id: string) => aliases.get(id) ?? id;
289
+ const ids = ctx.modelRegistry.getAvailable().filter((m) => m.provider === "llama-swap").map((m) => realOf(m.id));
290
+ return assignMechaUnits(ids, realOf(def));
291
+ }
292
+
181
293
  const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
182
294
  type MagiUnit = (typeof MAGI_UNITS)[number];
183
295
 
@@ -262,6 +374,7 @@ function animating(now = Date.now()): boolean {
262
374
  state.phase !== "idle" ||
263
375
  swap.state === "checking" ||
264
376
  swap.state === "loading" ||
377
+ (swap.state === "ready" && now - swap.since < ONLINE_MS) ||
265
378
  state.compacting ||
266
379
  now - state.rebornAt < REBIRTH_MS ||
267
380
  now - state.lastFailAt < FAIL_FLASH_MS
@@ -389,75 +502,193 @@ interface UnitView {
389
502
 
390
503
  const MAGI_DIAGRAM_WIDTH = 32;
391
504
 
505
+ /* ── MAGI screen: pixel art drawn with half blocks, two pixels per cell (one above the other) ── */
506
+
392
507
  /**
393
- * The three MAGI wired in a triangle: BALTHASAR on top, CASPAR bottom-left, MELCHIOR bottom-right,
394
- * joined through the central hub. Always exactly 32 cells wide.
508
+ * The three MAGI as the panels of their control screen, 32×20 pixels: B = BALTHASAR, C = CASPAR, M = MELCHIOR
509
+ * (CASPAR and MELCHIOR are mirror images), o = the wires joining them.
395
510
  */
396
- function magiDiagram(th: Theme, top: UnitView, left: UnitView, right: UnitView, link: ThemeColor, hub: string): string[] {
397
- const center = (s: string) => {
398
- const w = visibleWidth(s);
399
- const l = Math.max(0, Math.floor((10 - w) / 2));
400
- return " ".repeat(l) + s + " ".repeat(Math.max(0, 10 - w - l));
511
+ const MAGI_SCREEN = [
512
+ "..........BBBBBBBBBBBB..........",
513
+ "..........BBBBBBBBBBBB..........",
514
+ "..........BBBBBBBBBBBB..........",
515
+ "..........BBBBBBBBBBBB..........",
516
+ "..........BBBBBBBBBBBB..........",
517
+ "..........BBBBBBBBBBBB..........",
518
+ "..........BBBBBBBBBBBB..........",
519
+ "..........BBBBBBBBBBBB..........",
520
+ "...........BBBBBBBBBB...........",
521
+ "...........oBBBBBBBBo...........",
522
+ "CCCCCCCC..oo.BBBBBB.oo..MMMMMMMM",
523
+ "CCCCCCCCCoo..........ooMMMMMMMMM",
524
+ "CCCCCCCCCC............MMMMMMMMMM",
525
+ "CCCCCCCCCCC..........MMMMMMMMMMM",
526
+ "CCCCCCCCCCCC........MMMMMMMMMMMM",
527
+ "CCCCCCCCCCCCC......MMMMMMMMMMMMM",
528
+ "CCCCCCCCCCCCC......MMMMMMMMMMMMM",
529
+ "CCCCCCCCCCCCCooooooMMMMMMMMMMMMM",
530
+ "CCCCCCCCCCCCC......MMMMMMMMMMMMM",
531
+ "CCCCCCCCCCCCC......MMMMMMMMMMMMM",
532
+ ];
533
+
534
+ interface ScreenLabel {
535
+ row: number;
536
+ center: number;
537
+ text: string;
538
+ fg: string; // SGR foreground escape
539
+ }
540
+
541
+ const SCREEN_TEAL = "\x1b[38;2;64;180;220m";
542
+ const SCREEN_TEAL_DARK = "\x1b[38;2;22;62;78m";
543
+ const SCREEN_INK = "\x1b[38;2;10;10;10m";
544
+ const SGR_RESET = "\x1b[0m";
545
+ const toBg = (fg: string) => fg.replace("[38;", "[48;");
546
+
547
+ /** Paints each pixel of a rows×2 by cols grid, then writes the labels in bold over whatever is under them. */
548
+ function renderScreen(rows: number, cols: number, at: (y: number, x: number) => string | undefined, labels: readonly ScreenLabel[]): string[] {
549
+ const cells = Array.from({ length: rows }, (_, r) =>
550
+ Array.from({ length: cols }, (_, c) => {
551
+ const top = at(r * 2, c);
552
+ const bot = at(r * 2 + 1, c);
553
+ if (!top && !bot) return " ";
554
+ if (top === bot) return top + "█" + SGR_RESET;
555
+ if (top && bot) return top + toBg(bot) + "▀" + SGR_RESET;
556
+ return top ? top + "▀" + SGR_RESET : bot + "▄" + SGR_RESET;
557
+ }),
558
+ );
559
+ for (const l of labels) {
560
+ const chars = [...l.text];
561
+ const start = Math.round(l.center - chars.length / 2);
562
+ chars.forEach((ch, i) => {
563
+ const c = start + i;
564
+ const row = cells[l.row];
565
+ if (!row || c < 0 || c >= row.length) return;
566
+ const under = at(l.row * 2, c);
567
+ row[c] = (under ? toBg(under) : "") + l.fg + "\x1b[1m" + ch + SGR_RESET;
568
+ });
569
+ }
570
+ return cells.map((r) => r.join(""));
571
+ }
572
+
573
+ /* ── the angel attack: red spreads through the MAGI while a model loads, blue takes them back when it is ready ── */
574
+
575
+ const SCREEN_RED = "\x1b[38;2;206;36;24m";
576
+ /** CASPAR's bottom-left corner: the red never gets there; once everything else has fallen it blinks until the model is loaded. */
577
+ const SAFE_PIXELS = new Set(["18,0", "18,1", "19,0", "19,1"]);
578
+ const LOAD_DEFAULT_MS = 60_000;
579
+ const RECOVER_MS = 1_600;
580
+ const ONLINE_MS = 3_000;
581
+
582
+ /** Blocky noise (2×2 pixel blocks), stable across renders: makes the front ragged like a corrupted screen. */
583
+ const blockNoise = (y: number, x: number) => {
584
+ const h = Math.sin(Math.floor(y / 2) * 127.1 + Math.floor(x / 2) * 311.7) * 43758.5453;
585
+ return h - Math.floor(h);
586
+ };
587
+
588
+ /** Pixel → 0..1 order in which it falls: BALTHASAR, then MELCHIOR, then CASPAR, each from where the previous one touches it. */
589
+ const INFECTION_ORDER: ReadonlyMap<string, number> = (() => {
590
+ const entry: Record<string, { unit: number; y: number; x: number }> = {
591
+ B: { unit: 0, y: 0, x: 10 },
592
+ M: { unit: 1, y: 10, x: 22 },
593
+ C: { unit: 2, y: 10, x: 9 },
401
594
  };
402
- const edge = (u: UnitView, s: string) => th.fg(u.lit ? u.tone : "dim", s);
403
- const name = (u: UnitView) => (u.lit ? th.bold(th.fg(u.tone, center(u.name))) : th.fg("muted", center(u.name)));
404
- const stat = (u: UnitView) => th.fg(u.lit ? u.tone : "dim", center(u.status));
405
- const L = (s: string) => th.fg(link, s);
406
- const sp = (n: number) => " ".repeat(n);
595
+ const byUnit = new Map<string, { key: string; d: number }[]>();
596
+ MAGI_SCREEN.forEach((row, y) =>
597
+ [...row].forEach((ch, x) => {
598
+ const e = entry[ch];
599
+ if (!e || SAFE_PIXELS.has(`${y},${x}`)) return;
600
+ const list = byUnit.get(ch) ?? [];
601
+ list.push({ key: `${y},${x}`, d: Math.hypot(y - e.y, x - e.x) + blockNoise(y, x) * 7 });
602
+ byUnit.set(ch, list);
603
+ }),
604
+ );
605
+ const order = new Map<string, number>();
606
+ for (const [ch, list] of byUnit) {
607
+ list.sort((a, b) => a.d - b.d);
608
+ list.forEach((p, i) => order.set(p.key, (entry[ch]!.unit + i / list.length) / 3));
609
+ }
610
+ return order;
611
+ })();
612
+
613
+ /** Pixel → 0..1 order in which blue takes it back, spreading from the safe corner. */
614
+ const RECOVERY_ORDER: ReadonlyMap<string, number> = (() => {
615
+ const list: { key: string; d: number }[] = [];
616
+ MAGI_SCREEN.forEach((row, y) =>
617
+ [...row].forEach((ch, x) => {
618
+ if ("BCM".includes(ch)) list.push({ key: `${y},${x}`, d: Math.hypot(y - 19, x) + blockNoise(y, x) * 7 });
619
+ }),
620
+ );
621
+ list.sort((a, b) => a.d - b.d);
622
+ return new Map(list.map((p, i) => [p.key, i / list.length]));
623
+ })();
624
+
625
+ /** How far the attack went (0..1) and how far the recovery is (0..1); blink toggles the safe corner. */
626
+ interface Virus {
627
+ infected: number;
628
+ recovered: number;
629
+ blink: boolean;
630
+ }
407
631
 
408
- return [
409
- sp(10) + edge(top, "┌──────────┐") + sp(10),
410
- sp(6) + L("╭───") + edge(top, "┤") + name(top) + edge(top, "├") + L("───╮") + sp(6),
411
- sp(6) + L("│") + sp(3) + edge(top, "│") + stat(top) + edge(top, "│") + sp(3) + L("│") + sp(6),
412
- sp(6) + L("│") + sp(3) + edge(top, "└──────────┘") + sp(3) + L("│") + sp(6),
413
- " " + edge(left, "┌────") + L("┴") + edge(left, "─────┐") + sp(6) + edge(right, "┌─────") + L("┴") + edge(right, "────┐") + " ",
414
- " " + edge(left, "│") + name(left) + edge(left, "│") + L(hub) + edge(right, "│") + name(right) + edge(right, "│") + " ",
415
- " " + edge(left, "│") + stat(left) + edge(left, "│") + sp(6) + edge(right, "│") + stat(right) + edge(right, "│") + " ",
416
- " " + edge(left, "└──────────┘") + sp(6) + edge(right, "└──────────┘") + " ",
632
+ /**
633
+ * The MAGI screen with each unit's name and status: a lit unit is filled (blue, or its state's color),
634
+ * a dark one is dimmed; the wires and the hub stay orange. 32×10 cells.
635
+ */
636
+ function magiDiagram(th: Theme, top: UnitView, left: UnitView, right: UnitView, hub: string, virus?: Virus): string[] {
637
+ const fill = (u: UnitView) => (!u.lit ? SCREEN_TEAL_DARK : u.tone === "accent" ? SCREEN_TEAL : th.getFgAnsi(u.tone));
638
+ const ink = (u: UnitView) => (u.lit ? SCREEN_INK : th.getFgAnsi("muted"));
639
+ const wire = th.getFgAnsi("accent");
640
+ const unit = (u: UnitView, row: number, name: number, status: number): ScreenLabel[] => [
641
+ { row, center: name, text: u.name, fg: ink(u) },
642
+ { row: row + 1, center: status, text: u.status, fg: ink(u) },
417
643
  ];
644
+ const paint: Record<string, string> = { B: fill(top), C: fill(left), M: fill(right), o: wire };
645
+ const at = (y: number, x: number) => {
646
+ const ch = MAGI_SCREEN[y]![x]!;
647
+ if (virus && ch !== "o" && ch !== ".") {
648
+ const key = `${y},${x}`;
649
+ // the last stronghold: it blinks only once everything else has fallen, until the recovery starts
650
+ if (SAFE_PIXELS.has(key)) return virus.infected >= 1 && virus.recovered <= 0 && virus.blink ? SCREEN_TEAL_DARK : SCREEN_TEAL;
651
+ if ((INFECTION_ORDER.get(key) ?? 1) < virus.infected && (RECOVERY_ORDER.get(key) ?? 0) >= virus.recovered) return SCREEN_RED;
652
+ }
653
+ return paint[ch];
654
+ };
655
+ return renderScreen(
656
+ 10,
657
+ MAGI_DIAGRAM_WIDTH,
658
+ at,
659
+ [...unit(top, 2, 16, 16), ...unit(left, 7, 6, 6), ...unit(right, 7, 26, 26), { row: 7, center: 16, text: hub, fg: wire }],
660
+ );
418
661
  }
419
662
 
420
663
  /* ─────────────────────────────────────────────────────────── header ── */
421
664
 
422
665
  /** Static header: it scrolls away with the conversation, so nothing here animates. */
423
666
  function buildHeader(theme: Theme) {
424
- const tree = renderTreeOfLife(theme);
425
-
426
667
  return {
427
668
  render(width: number): string[] {
428
669
  const orange = (s: string) => theme.fg("accent", s);
429
- const dim = (s: string) => theme.fg("dim", s);
430
- const muted = (s: string) => theme.fg("muted", s);
431
- const triad = MAGI_UNITS.map((u) => theme.fg("success", u)).join(dim(" · "));
432
- const subtitle = "KETER → MALKUTH · 10 SEPHIROT · 22 PATHS";
433
- const lore = "THE MAGI JUDGE · THE GOLEM ACTS · THE SEALS KEEP TIME";
434
-
435
- // Narrow layout: a single identification line.
436
- if (width < 44) {
437
- return ["", orange(theme.bold("◆ MAGI")) + dim(" // ") + triad, ""].map((l) => truncateToWidth(l, width));
438
- }
439
-
440
- // Medium layout: tree on top, wordmark below.
441
- if (width < 70) {
442
- const lines = ["", ...tree, ""];
443
- for (const l of MAGI_WORD) lines.push(orange(l));
444
- lines.push(dim("├─ ") + triad + dim(" ─┤"), muted(subtitle), dim(lore), "");
445
- return lines.map((l) => truncateToWidth(l, width));
446
- }
447
-
448
- // Wide layout: tree left, wordmark right (rows chosen to center the text block on the tree).
449
- const [wordAt, triadAt] = [5, 12];
450
- const lines = [""];
451
- for (let i = 0; i < tree.length; i++) {
452
- let right = "";
453
- if (i >= wordAt && i < wordAt + MAGI_WORD.length) right = orange(MAGI_WORD[i - wordAt]!);
454
- else if (i === triadAt) right = dim("├─ ") + triad + dim(" ─┤");
455
- else if (i === triadAt + 1) right = muted(subtitle);
456
- else if (i === triadAt + 2) right = dim(lore);
457
- lines.push(truncateToWidth(tree[i]! + " " + right, width));
670
+ const note = theme.fg("dim", "fan-art theme inspired by Neon Genesis Evangelion · all rights reserved to khara, Inc.");
671
+ const oneLine = MAGI_WORD.map((l, i) => l + " " + SYSTEM_WORD[i]);
672
+ const stacked = [...MAGI_WORD, "", ...SYSTEM_WORD];
673
+ const word = width >= SEELE_WIDTH + 4 + oneLine[0]!.length ? oneLine : stacked;
674
+
675
+ let lines: string[];
676
+ if (width >= SEELE_WIDTH + 4 + word[0]!.length) {
677
+ // the seal on the left of the wordmark, both centered on the taller one
678
+ const h = Math.max(SEELE_LOGO.length, word.length);
679
+ const sealAt = Math.floor((h - SEELE_LOGO.length) / 2);
680
+ const wordAt = Math.floor((h - word.length) / 2);
681
+ lines = Array.from({ length: h }, (_, i) => {
682
+ const seal = (SEELE_LOGO[i - sealAt] ?? "").padEnd(SEELE_WIDTH);
683
+ const w = word[i - wordAt];
684
+ return theme.fg("text", seal) + (w ? " " + orange(w) : "");
685
+ });
686
+ } else if (width >= SYSTEM_WORD[0]!.length) {
687
+ lines = stacked.map(orange);
688
+ } else {
689
+ lines = [orange(theme.bold("◆ MAGI SYSTEM"))];
458
690
  }
459
- lines.push("");
460
- return lines;
691
+ return ["", ...lines, note, ""].map((l) => truncateToWidth(l, width));
461
692
  },
462
693
  invalidate() {},
463
694
  };
@@ -485,6 +716,8 @@ const swap = {
485
716
  state: "off" as SwapState,
486
717
  since: 0, // when the current state started
487
718
  loadMs: 0, // how long the last real load took
719
+ expectedLoadMs: LOAD_DEFAULT_MS, // how long this model took to load last time
720
+ infection: 0, // how far the red went when the model became ready (0 = it was already in VRAM)
488
721
  error: "",
489
722
  gpus: [] as GpuStat[],
490
723
  ramUsed: 0,
@@ -583,15 +816,70 @@ async function refreshSwapActivity(): Promise<void> {
583
816
  }
584
817
  }
585
818
 
819
+ /** Context filled right now in llama-server's busy slot: prompt tokens placed so far plus the ones generated. */
820
+ const live = { tokens: 0, at: 0 };
821
+ const LIVE_POLL_MS = 500;
822
+ const LIVE_STALE_MS = 2_000;
823
+
586
824
  /**
587
- * Makes sure the session model is in VRAM. Any request under /upstream/<model>/ makes llama-swap
588
- * load the model if needed, and it only answers once the model is ready.
825
+ * Reads the busy slot of the session model (llama-server /slots): n_prompt_tokens grows batch by batch
826
+ * while the prompt is processed, then token by token while the model thinks and answers.
827
+ * ponytail: with several slots it takes the fullest busy one, assuming it is this session's request.
589
828
  */
590
- async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined = ctx.model): Promise<void> {
829
+ async function refreshLiveContext(): Promise<void> {
830
+ if (!swap.base || swap.state !== "ready") return;
831
+ try {
832
+ const slots = (await (await swapGet(`/upstream/${encodeURIComponent(swap.realId)}/slots`, 1_500)).json()) as any[];
833
+ const busy = slots.filter((s) => s.is_processing && s.n_prompt_tokens > 0);
834
+ if (!busy.length) return;
835
+ live.tokens = Math.max(...busy.map((s) => s.n_prompt_tokens));
836
+ live.at = Date.now();
837
+ } catch {
838
+ // the pi estimate stays
839
+ }
840
+ }
841
+
842
+ /** Context usage for the seals: live from the slot while the model works, pi's estimate otherwise. */
843
+ function contextUsage(): { tokens: number | null; contextWindow: number; percent: number; live: boolean } | undefined {
844
+ const usage = liveCtx?.getContextUsage?.();
845
+ if (!usage) return undefined;
846
+ if (state.phase !== "idle" && usage.contextWindow && Date.now() - live.at < LIVE_STALE_MS) {
847
+ return { tokens: live.tokens, contextWindow: usage.contextWindow, percent: Math.min(100, (live.tokens / usage.contextWindow) * 100), live: true };
848
+ }
849
+ return { tokens: usage.tokens, contextWindow: usage.contextWindow, percent: usage.percent ?? 0, live: false };
850
+ }
851
+
852
+ /** id → the model llama-swap actually runs for it: an alias (e.g. "… - Instruct") runs another model. */
853
+ async function swapAliases(): Promise<Map<string, string>> {
854
+ const aliases = new Map<string, string>();
855
+ try {
856
+ const { data } = (await (await swapGet("/v1/models")).json()) as { data?: any[] };
857
+ for (const m of data ?? []) {
858
+ const ls = m.meta?.llamaswap;
859
+ aliases.set(m.id, ls?.type === "alias" && ls.modelID ? ls.modelID : m.id);
860
+ }
861
+ } catch {
862
+ // ids stay as they are
863
+ }
864
+ return aliases;
865
+ }
866
+
867
+ /** Models llama-swap keeps in memory: real id → "ready" | "starting" | … (empty when the server is unreachable). */
868
+ async function swapRunning(): Promise<Map<string, string>> {
869
+ try {
870
+ const { running } = (await (await swapGet("/running")).json()) as { running?: { model: string; state: string }[] };
871
+ return new Map((running ?? []).map((r) => [r.model, r.state]));
872
+ } catch {
873
+ return new Map();
874
+ }
875
+ }
876
+
877
+ /** Points the llama-swap monitor at the server behind `model`, without loading anything; false when llama-swap doesn't serve it. */
878
+ async function connectSwap(ctx: ExtensionContext, model: Model<any> | undefined): Promise<boolean> {
591
879
  if (!model || model.provider !== "llama-swap" || !model.baseUrl) {
592
880
  swap.base = "";
593
881
  setSwapState("off");
594
- return;
882
+ return false;
595
883
  }
596
884
  const id = model.id;
597
885
  swap.modelId = id;
@@ -605,14 +893,27 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
605
893
  };
606
894
  void refreshSwapMetrics();
607
895
  void refreshSwapActivity();
608
- // an alias (e.g. "… - Instruct") runs another model: /running lists that one
609
- try {
610
- const { data } = (await (await swapGet("/v1/models")).json()) as { data?: any[] };
611
- const entry = data?.find((m) => m.id === id);
612
- if (entry?.meta?.llamaswap?.type === "alias" && entry.meta.llamaswap.modelID) swap.realId = entry.meta.llamaswap.modelID;
613
- } catch {
614
- // keep the id itself
615
- }
896
+ const aliases = await swapAliases();
897
+ if (swap.modelId !== id) return true;
898
+ swap.realId = aliases.get(id) ?? id;
899
+ return true;
900
+ }
901
+
902
+ /** Shows whether the session model is already in VRAM or asleep, without loading it: typing wakes it. */
903
+ async function probeModel(ctx: ExtensionContext, model: Model<any> | undefined = ctx.model): Promise<void> {
904
+ if (!(await connectSwap(ctx, model))) return;
905
+ const state = (await swapRunning()).get(swap.realId);
906
+ if (swap.modelId === model!.id) setSwapState(state === "ready" ? "ready" : "asleep");
907
+ }
908
+
909
+ /**
910
+ * Makes sure the session model is in VRAM. Any request under /upstream/<model>/ makes llama-swap
911
+ * load the model if needed, and it only answers once the model is ready.
912
+ */
913
+ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined = ctx.model): Promise<void> {
914
+ if (!(await connectSwap(ctx, model))) return;
915
+ const id = model!.id;
916
+ swap.expectedLoadMs = loadMagiConfig().loads?.[swap.realId] ?? LOAD_DEFAULT_MS;
616
917
 
617
918
  // no answer within 400ms → the model isn't in VRAM and is being loaded
618
919
  const slow = setTimeout(() => {
@@ -623,8 +924,15 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
623
924
  const res = await swapGet(`/upstream/${encodeURIComponent(id)}/health`, 15 * 60_000);
624
925
  if (swap.modelId !== id) return; // the model changed meanwhile
625
926
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
626
- if (swap.state === "loading") swap.loadMs = Date.now() - started;
927
+ const loaded = swap.state === "loading";
928
+ if (loaded) {
929
+ swap.loadMs = Date.now() - started;
930
+ swap.infection = Math.min(1, swap.loadMs / swap.expectedLoadMs);
931
+ const cfg = loadMagiConfig();
932
+ saveMagiConfig({ ...cfg, loads: { ...cfg.loads, [swap.realId]: swap.loadMs } });
933
+ } else swap.infection = 0;
627
934
  setSwapState("ready");
935
+ if (!ctx.sessionManager.getBranch().some((e) => e.type === "message")) void prewarmPrefix(ctx.cwd);
628
936
  } catch (err) {
629
937
  if (swap.modelId === id) setSwapState("error", err instanceof Error ? err.message : String(err));
630
938
  } finally {
@@ -633,6 +941,77 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
633
941
  }
634
942
  }
635
943
 
944
+ /* ── prefix prewarm: a new session's system prompt and tools are processed before its first message ── */
945
+
946
+ /**
947
+ * ~/.pi/agent/magi-prefix.json: "<project dir>\n<model id>" → the request body every new session shares
948
+ * (the system message, tools and request options of pi's last real request), so the prewarmed text is identical.
949
+ */
950
+ const PREFIX_STORE_PATH = join(homedir(), ".pi", "agent", "magi-prefix.json");
951
+
952
+ const prewarm = { state: "" as "" | "running" | "done", tokens: 0, ms: 0, abort: undefined as AbortController | undefined };
953
+ let prefixMemo = "";
954
+
955
+ function loadPrefixStore(): Record<string, any> {
956
+ try {
957
+ return JSON.parse(readFileSync(PREFIX_STORE_PATH, "utf8"));
958
+ } catch {
959
+ return {};
960
+ }
961
+ }
962
+
963
+ /** Keeps the shared part of pi's real llama-swap request; written only when it changes (new tools, another thinking level…). */
964
+ function rememberPrefix(cwd: string, payload: any): void {
965
+ if (!swap.base || payload?.model !== swap.modelId || payload.messages?.[0]?.role !== "system") return;
966
+ const { stream, stream_options, max_tokens, max_completion_tokens, messages, ...options } = payload;
967
+ const body = { ...options, messages: [messages[0]] };
968
+ const json = JSON.stringify(body);
969
+ if (json === prefixMemo) return;
970
+ prefixMemo = json;
971
+ const store = loadPrefixStore();
972
+ store[`${cwd}\n${payload.model}`] = body;
973
+ writeFileSync(PREFIX_STORE_PATH, JSON.stringify(store));
974
+ }
975
+
976
+ /**
977
+ * Processes the stored prefix so llama-server checkpoints the state exactly where a new session's prompt diverges:
978
+ * the first message then costs its own tokens (seconds) instead of the whole system prompt and tools (~80s cold).
979
+ * ponytail: cuts at the ChatML user marker (Qwen and most llama.cpp templates); other templates are skipped.
980
+ */
981
+ async function prewarmPrefix(cwd: string): Promise<void> {
982
+ const body = loadPrefixStore()[`${cwd}\n${swap.modelId}`];
983
+ if (!body || prewarm.state === "running") return;
984
+ const abort = new AbortController();
985
+ prewarm.abort = abort;
986
+ prewarm.state = "running";
987
+ const started = Date.now();
988
+ const post = async (path: string, json: unknown): Promise<any> =>
989
+ (
990
+ await fetch(`${swap.base}/upstream/${encodeURIComponent(swap.realId)}${path}`, {
991
+ method: "POST",
992
+ headers: { ...swap.headers, "Content-Type": "application/json" },
993
+ body: JSON.stringify(json),
994
+ signal: abort.signal,
995
+ })
996
+ ).json();
997
+ try {
998
+ // the template renders only with a user turn: the prefix ends where that turn starts
999
+ const { prompt } = await post("/apply-template", { ...body, messages: [...body.messages, { role: "user", content: "." }] });
1000
+ const cut = typeof prompt === "string" ? prompt.indexOf("<|im_start|>user") : -1;
1001
+ if (cut <= 0) {
1002
+ prewarm.state = "";
1003
+ return;
1004
+ }
1005
+ const { timings } = await post("/completion", { prompt: prompt.slice(0, cut), n_predict: 1, cache_prompt: true });
1006
+ prewarm.tokens = (timings?.prompt_n ?? 0) + (timings?.cache_n ?? 0);
1007
+ prewarm.ms = Date.now() - started;
1008
+ prewarm.state = "done";
1009
+ } catch {
1010
+ // aborted by the first message, or the server went away
1011
+ prewarm.state = "";
1012
+ }
1013
+ }
1014
+
636
1015
  /* ── /magi-ui status: a report built from the last requests llama-swap recorded ── */
637
1016
 
638
1017
  interface ActivityRow {
@@ -809,40 +1188,44 @@ class MagiPanel implements Component {
809
1188
  } else {
810
1189
  let word: string;
811
1190
  let tone: ThemeColor;
812
- let link: ThemeColor = "dim";
813
1191
  let hub = "─MAGI─";
814
1192
  let lit = (i: number) => flicker(f, i);
1193
+ let virus: Virus | undefined;
815
1194
  const breathing = ["─MAGI─", "━MAGI━"][f % 2]!;
816
1195
 
817
1196
  if (state.compacting) {
818
1197
  title = `COMPACTING · ${state.compactBy || "context"} · ${secs(state.compactSince)}`;
819
- [word, tone, link, hub, titleTone] = ["COMPACTING", "warning", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
1198
+ [word, tone, hub, titleTone] = ["COMPACTING", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
820
1199
  } else if (now - state.rebornAt < REBIRTH_MS) {
821
1200
  title = "SEVENTH SEAL OPENED";
822
- [word, tone, link, hub, titleTone] = ["REBORN", "success", "success", "═MAGI═", "success"];
1201
+ [word, tone, hub, titleTone] = ["REBORN", "success", "═MAGI═", "success"];
823
1202
  lit = () => true;
824
1203
  } else if (idle && (swap.state === "checking" || swap.state === "loading")) {
825
1204
  title = swap.state === "loading" ? `LOADING MODEL · ${secs(swap.since)}` : "CHECKING MODEL";
826
- [word, tone, link, hub, titleTone] = ["LOADING", "warning", "warning", breathing, "warning"];
1205
+ [word, tone, hub, titleTone] = ["LOADING", "accent", breathing, "warning"];
1206
+ lit = () => true;
1207
+ if (swap.state === "loading")
1208
+ virus = { infected: Math.min(1, (now - swap.since) / swap.expectedLoadMs), recovered: 0, blink: f % 4 < 2 };
827
1209
  } else if (idle && swap.state === "error") {
828
1210
  title = "MAGI OFFLINE";
829
- [word, tone, link, hub, titleTone] = ["OFFLINE", "error", "error", "─ ╳ ──", "error"];
1211
+ [word, tone, hub, titleTone] = ["OFFLINE", "error", "─ ╳ ──", "error"];
830
1212
  lit = () => f % 16 < 8;
831
1213
  } else if (idle && swap.state === "asleep") {
832
1214
  title = "MODEL ASLEEP · type to wake";
833
1215
  [word, tone, titleTone] = ["ASLEEP", "muted", "muted"];
834
1216
  lit = () => false;
835
- } else if (idle && swap.state === "ready" && now - swap.since < 3000) {
1217
+ } else if (idle && swap.state === "ready" && now - swap.since < ONLINE_MS) {
836
1218
  title = swap.loadMs ? `MAGI ONLINE · ${fmtMs(swap.loadMs)}` : "MAGI ONLINE";
837
- [word, tone, link, hub, titleTone] = ["ONLINE", "success", "success", "═MAGI═", "success"];
1219
+ [word, tone, hub, titleTone] = ["ONLINE", "accent", "═MAGI═", "success"];
838
1220
  lit = () => true;
1221
+ virus = { infected: swap.infection, recovered: (now - swap.since) / RECOVER_MS, blink: f % 4 < 2 };
839
1222
  } else if (state.phase === "thinking") {
840
1223
  title = `THINKING · ${secs(state.phaseSince)}`;
841
- [word, tone, link, hub] = ["THINKING", "accent", "accent", breathing];
1224
+ [word, tone, hub] = ["THINKING", "accent", breathing];
842
1225
  } else if (state.phase === "responding") {
843
1226
  const tps = liveTps();
844
1227
  title = `RESPONDING${tps ? ` · ${tps.toFixed(1)} tok/s` : ""}`;
845
- [word, tone, link, hub, titleTone] = ["RESPONDING", "success", "success", breathing, "success"];
1228
+ [word, tone, hub, titleTone] = ["RESPONDING", "success", breathing, "success"];
846
1229
  } else {
847
1230
  title = "STANDBY";
848
1231
  [word, tone, titleTone] = ["STANDBY", "muted", "muted"];
@@ -850,7 +1233,7 @@ class MagiPanel implements Component {
850
1233
  }
851
1234
 
852
1235
  const units = MAGI_UNITS.map((name, i) => ({ name, status: word, tone, lit: lit(i) }));
853
- body = magiDiagram(th, units[1]!, units[2]!, units[0]!, link, hub);
1236
+ body = magiDiagram(th, units[1]!, units[2]!, units[0]!, hub, virus);
854
1237
  const c = state.lastCouncil;
855
1238
  status = c
856
1239
  ? `${pulse} ${th.fg("dim", "COUNCIL ")}${th.fg(voteTone(c.verdict), `${c.verdict ?? "NO QUORUM"} ${c.tally}/3`)}`
@@ -911,9 +1294,10 @@ class MagiPanel implements Component {
911
1294
  );
912
1295
 
913
1296
  // SEALS: the context window, one seal per seventh
914
- const usage = liveCtx?.getContextUsage?.();
1297
+ const usage = contextUsage();
915
1298
  const percent = usage?.percent ?? 0;
916
- out.push(this.frameLine(` ${th.fg("dim", "SEALS".padEnd(9))}${renderSeals(th, percent)} ${th.fg(usageTone(percent), `${percent.toFixed(0)}%`)}`, inner));
1299
+ const liveMark = usage?.live ? th.fg("accent", " ▲ live") : "";
1300
+ out.push(this.frameLine(` ${th.fg("dim", "SEALS".padEnd(9))}${renderSeals(th, percent)} ${th.fg(usageTone(percent), `${percent.toFixed(0)}%`)}${liveMark}`, inner));
917
1301
  if (!compact && usage?.contextWindow) {
918
1302
  out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
919
1303
  }
@@ -968,7 +1352,13 @@ class MagiPanel implements Component {
968
1352
  ? th.fg("error", "OFFLINE")
969
1353
  : th.fg("warning", swap.state === "loading" ? `LOADING ${secsSince(swap.since)}s` : "CHECKING");
970
1354
  const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
971
- out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}`, inner));
1355
+ const warm =
1356
+ prewarm.state === "running"
1357
+ ? th.fg("warning", " · PREWARM")
1358
+ : prewarm.state === "done"
1359
+ ? th.fg("dim", ` · ${fmtTokens(prewarm.tokens)} warm`)
1360
+ : "";
1361
+ out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}${warm}`, inner));
972
1362
  if (compact) return [...out, this.costRow(inner)];
973
1363
  if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
974
1364
  const gib = (n: number) => n / 2 ** 30;
@@ -1094,7 +1484,10 @@ interface MagiUnitConfig {
1094
1484
  model?: string;
1095
1485
  thinking?: string;
1096
1486
  }
1097
- type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & { ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency } };
1487
+ type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & {
1488
+ ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency };
1489
+ loads?: Record<string, number>; // real model id → ms its last load took
1490
+ };
1098
1491
 
1099
1492
  const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
1100
1493
 
@@ -1280,10 +1673,9 @@ function buildDeliberationView(
1280
1673
  return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
1281
1674
  });
1282
1675
  const { verdict, tally } = tallyVerdict(opinions.map((o) => o?.vote ?? null));
1283
- const link: ThemeColor = done ? voteTone(verdict) : "accent";
1284
1676
  const hub = done ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][tick % 2]!;
1285
1677
  const left = " ".repeat(Math.max(0, Math.floor((inner - MAGI_DIAGRAM_WIDTH) / 2)));
1286
- for (const l of magiDiagram(theme, units[1]!, units[2]!, units[0]!, link, hub)) out.push(row(left + l));
1678
+ for (const l of magiDiagram(theme, units[1]!, units[2]!, units[0]!, hub)) out.push(row(left + l));
1287
1679
 
1288
1680
  out.push(row(""));
1289
1681
  for (let i = 0; i < MAGI.length; i++) {
@@ -1336,6 +1728,73 @@ function buildReportView(theme: Theme, lines: string[], close: () => void) {
1336
1728
  };
1337
1729
  }
1338
1730
 
1731
+ interface MechaEntry {
1732
+ model: Model<any>;
1733
+ unit: MechaUnit;
1734
+ state: MechaState;
1735
+ }
1736
+
1737
+ /** MECHA SELECT: the model picker of a new session. The highlighted unit's head is animated by its real state in llama-swap. */
1738
+ function buildMechaPicker(tui: TUI, theme: Theme, entries: MechaEntry[], start: number, close: (picked: MechaEntry | undefined) => void) {
1739
+ let frame = 0;
1740
+ let index = start;
1741
+ const timer = setInterval(() => {
1742
+ frame++;
1743
+ tui.requestRender();
1744
+ }, 125);
1745
+ const nameWidth = Math.max(...entries.map((e) => visibleWidth(e.model.id)));
1746
+
1747
+ return {
1748
+ render(width: number): string[] {
1749
+ const dim = (s: string) => theme.fg("dim", s);
1750
+ const inner = Math.max(40, Math.min(width - 2, 96));
1751
+ const row = (s: string) => {
1752
+ const t = truncateToWidth(s, inner);
1753
+ return theme.fg("accent", "║") + t + " ".repeat(Math.max(0, inner - visibleWidth(t))) + theme.fg("accent", "║");
1754
+ };
1755
+ const sel = entries[index]!;
1756
+ const head = renderMechaHead(sel.unit, sel.state, frame);
1757
+ const list = entries.map((e, i) => {
1758
+ const u = MECHA[e.unit];
1759
+ const mark = i === index ? theme.fg("accent", "▸ ") : " ";
1760
+ const unit = rgb(e.state === "dormant" ? shade(u.armor, 0.6) : u.armor, u.name.padEnd(10));
1761
+ const name = i === index ? theme.bold(theme.fg("text", e.model.id)) : theme.fg("muted", e.model.id);
1762
+ const status =
1763
+ e.state === "active" ? theme.fg("success", "● IN VRAM") : e.state === "waking" ? theme.fg("warning", "◌ WAKING") : dim("○ dormant");
1764
+ return mark + unit + name + " ".repeat(nameWidth - visibleWidth(e.model.id) + 2) + status;
1765
+ });
1766
+ const u = MECHA[sel.unit];
1767
+ const sync =
1768
+ sel.state === "active"
1769
+ ? theme.fg("success", `${u.name} · SYNC READY · no wait`)
1770
+ : sel.state === "waking"
1771
+ ? theme.fg("warning", `${u.name} · LIFT OFF`)
1772
+ : theme.fg("muted", `${u.name} · DORMANT · it will be loaded into VRAM`);
1773
+
1774
+ const out = [
1775
+ theme.fg("accent", "╔" + "═".repeat(inner) + "╗"),
1776
+ row(theme.bold(theme.fg("accent", " MECHA SELECT")) + dim(" :: choose the unit to activate")),
1777
+ theme.fg("accent", "╟" + "─".repeat(inner) + "╢"),
1778
+ ];
1779
+ for (let r = 0; r < Math.max(head.length, list.length); r++) out.push(row(` ${head[r] ?? " ".repeat(13)} ${list[r] ?? ""}`));
1780
+ out.push(theme.fg("accent", "╟" + "─".repeat(inner) + "╢"), row(" " + sync), theme.fg("accent", "╚" + "═".repeat(inner) + "╝"));
1781
+ out.push(dim(" ↑↓ select · enter: activate · esc: keep the current model, it loads when you type"));
1782
+ return out.map((l) => truncateToWidth(l, width));
1783
+ },
1784
+ handleInput(data: string) {
1785
+ if (matchesKey(data, "up")) index = (index + entries.length - 1) % entries.length;
1786
+ else if (matchesKey(data, "down")) index = (index + 1) % entries.length;
1787
+ else if (matchesKey(data, "enter")) close(entries[index]);
1788
+ else if (matchesKey(data, "escape")) close(undefined);
1789
+ tui.requestRender();
1790
+ },
1791
+ invalidate() {},
1792
+ dispose() {
1793
+ clearInterval(timer);
1794
+ },
1795
+ };
1796
+ }
1797
+
1339
1798
  async function configureMagi(ctx: ExtensionContext): Promise<void> {
1340
1799
  const cfg = loadMagiConfig();
1341
1800
  const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
@@ -1442,7 +1901,7 @@ function footerLeft(th: Theme, now = Date.now()): string {
1442
1901
  dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
1443
1902
  );
1444
1903
  }
1445
- const percent = liveCtx?.getContextUsage?.()?.percent ?? 0;
1904
+ const percent = contextUsage()?.percent ?? 0;
1446
1905
  if (percent >= SIXTH_SEAL_PERCENT) {
1447
1906
  const cmd = state.hasSmartCompact ? "/smart-compact" : "/compact";
1448
1907
  return (
@@ -1613,10 +2072,47 @@ export default function (pi: ExtensionAPI) {
1613
2072
  let unsubscribeInput: (() => void) | undefined;
1614
2073
  let lastPoll = 0;
1615
2074
  let titleTimer: ReturnType<typeof setInterval> | undefined;
2075
+ let liveTimer: ReturnType<typeof setInterval> | undefined;
1616
2076
  /** Tools currently executing, by call id: the golem shows one of them, and leaves when none is left. */
1617
2077
  const runningTools = new Map<string, { name: string; target: string }>();
1618
2078
 
1619
- pi.on("session_start", async (_event, ctx) => {
2079
+ /** The picker has the keyboard: keys pressed there must not wake the default model. */
2080
+ let pickerOpen = false;
2081
+
2082
+ /** MECHA SELECT on a new llama-swap session: nothing goes into VRAM until a unit is chosen. */
2083
+ const pickModel = async (ctx: ExtensionContext) => {
2084
+ const current = ctx.model!;
2085
+ const models = ctx.modelRegistry.getAvailable().filter((m) => m.provider === "llama-swap");
2086
+ if (models.length < 2) return;
2087
+ pickerOpen = true;
2088
+ try {
2089
+ const [aliases, running] = await Promise.all([swapAliases(), swapRunning()]);
2090
+ const realOf = (m: Model<any>) => aliases.get(m.id) ?? m.id;
2091
+ const units = mechaUnitsOf(ctx, aliases);
2092
+ const rank: MechaUnit[] = ["I", "II", "III", "LEGION"];
2093
+ const entries: MechaEntry[] = models
2094
+ .map((model) => {
2095
+ const st = running.get(realOf(model));
2096
+ const mechaState: MechaState = st === "ready" ? "active" : st === "starting" ? "waking" : "dormant";
2097
+ return { model, unit: units.get(realOf(model))!, state: mechaState };
2098
+ })
2099
+ .sort((a, b) => rank.indexOf(a.unit) - rank.indexOf(b.unit));
2100
+ // the session's model if it is in VRAM, else any unit already in VRAM (no wait), else the session's model
2101
+ const own = entries.findIndex((e) => e.model.id === current.id);
2102
+ let start = entries[own]?.state === "active" ? own : entries.findIndex((e) => e.state === "active");
2103
+ if (start < 0) start = Math.max(0, own);
2104
+
2105
+ const picked = await ctx.ui.custom<MechaEntry | undefined>((tui, theme, _keys, done) => buildMechaPicker(tui, theme, entries, start, done));
2106
+ if (!picked) return;
2107
+ // same model: pi emits no model_select, so load it here
2108
+ if (picked.model.id === current.id) return void preloadModel(ctx, picked.model);
2109
+ if (!(await pi.setModel(picked.model))) ctx.ui.notify(`${picked.model.id}: no credentials for llama-swap`, "error");
2110
+ } finally {
2111
+ pickerOpen = false;
2112
+ }
2113
+ };
2114
+
2115
+ pi.on("session_start", async (event, ctx) => {
1620
2116
  liveCtx = ctx;
1621
2117
  recountSession(ctx);
1622
2118
  state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
@@ -1626,8 +2122,9 @@ export default function (pi: ExtensionAPI) {
1626
2122
  ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
1627
2123
  if (ctx.mode !== "tui") return;
1628
2124
  applyChrome(ctx);
1629
- // load the model into VRAM now (animated in the panel and footer)
1630
- void preloadModel(ctx);
2125
+ // nothing is loaded at startup: a new session picks its MECHA unit, a resumed one shows whether its model is in VRAM
2126
+ const fresh = event.reason === "new" || (event.reason === "startup" && !ctx.sessionManager.getBranch().some((e) => e.type === "message"));
2127
+ void probeModel(ctx).then(() => (fresh && chrome && swap.base ? pickModel(ctx) : undefined));
1631
2128
  // GPU stats every 3s while something happens, every 30s when idle
1632
2129
  metricsTimer ??= setInterval(() => {
1633
2130
  const now = Date.now();
@@ -1638,13 +2135,18 @@ export default function (pi: ExtensionAPI) {
1638
2135
  .then(repaint);
1639
2136
  }, BUSY_POLL_MS);
1640
2137
  metricsTimer.unref?.();
2138
+ // the seals follow the context live while the model works
2139
+ liveTimer ??= setInterval(() => {
2140
+ if (state.phase !== "idle") void refreshLiveContext().then(repaint);
2141
+ }, LIVE_POLL_MS);
2142
+ liveTimer.unref?.();
1641
2143
  // back at the keyboard: clear the ✓ from the title, wake a model llama-swap unloaded
1642
2144
  unsubscribeInput ??= ctx.ui.onTerminalInput(() => {
1643
2145
  if (titleDone) {
1644
2146
  titleDone = false;
1645
2147
  ctx.ui.setTitle(windowTitle(ctx.cwd));
1646
2148
  }
1647
- if (swap.state === "asleep" && liveCtx) void preloadModel(liveCtx);
2149
+ if (swap.state === "asleep" && liveCtx && !pickerOpen) void preloadModel(liveCtx);
1648
2150
  return undefined;
1649
2151
  });
1650
2152
  // ponytail: re-applies the title every 2s because pi overwrites it without telling extensions
@@ -1660,18 +2162,25 @@ export default function (pi: ExtensionAPI) {
1660
2162
  repaint();
1661
2163
  });
1662
2164
 
2165
+ pi.on("before_provider_request", (event, ctx) => {
2166
+ rememberPrefix(ctx.cwd, event.payload);
2167
+ });
2168
+
1663
2169
  pi.on("model_select", async (event, ctx) => {
1664
2170
  liveCtx = ctx;
1665
2171
  if (ctx.mode === "tui") void preloadModel(ctx, event.model);
1666
2172
  });
1667
2173
 
1668
2174
  pi.on("session_shutdown", async () => {
2175
+ prewarm.abort?.abort();
1669
2176
  clearInterval(metricsTimer);
1670
2177
  metricsTimer = undefined;
1671
2178
  unsubscribeInput?.();
1672
2179
  unsubscribeInput = undefined;
1673
2180
  clearInterval(titleTimer);
1674
2181
  titleTimer = undefined;
2182
+ clearInterval(liveTimer);
2183
+ liveTimer = undefined;
1675
2184
  hidePanel();
1676
2185
  });
1677
2186
 
@@ -1681,6 +2190,7 @@ export default function (pi: ExtensionAPI) {
1681
2190
 
1682
2191
  pi.on("turn_start", async (_event, ctx) => {
1683
2192
  liveCtx = ctx;
2193
+ prewarm.abort?.abort();
1684
2194
  state.turns++;
1685
2195
  setPhase("thinking");
1686
2196
  if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage("the MAGI deliberate…");
@@ -1855,10 +2365,15 @@ export default function (pi: ExtensionAPI) {
1855
2365
  }
1856
2366
 
1857
2367
  pi.registerCommand("magi", {
1858
- description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi review [focus] judges the git diff; /magi config assigns models",
2368
+ description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi review [focus] judges the git diff; /magi config assigns models; /magi mecha picks the model",
1859
2369
  handler: async (args, ctx) => {
1860
2370
  const arg = args.trim();
1861
2371
  if (arg === "config") return configureMagi(ctx);
2372
+ if (arg === "mecha") {
2373
+ if (ctx.mode !== "tui" || !swap.base) return ctx.ui.notify("MECHA SELECT needs the TUI and a llama-swap model", "error");
2374
+ liveCtx = ctx;
2375
+ return pickModel(ctx);
2376
+ }
1862
2377
 
1863
2378
  if (arg === "review" || arg.startsWith("review ")) {
1864
2379
  const focus = arg.slice("review".length).trim();
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "pi-magi-theme",
3
- "version": "0.1.4",
4
- "description": "MAGI theme + extension for pi: Tree of Life header, three-model /magi council, golem tool animations, seven-seal context gauge, llama-swap telemetry",
3
+ "version": "0.2.1",
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",
7
7
  "pi-extension",
8
8
  "pi-theme",
9
9
  "pi",
10
10
  "magi",
11
- "tree-of-life",
11
+ "evangelion",
12
12
  "llama-swap"
13
13
  ],
14
14
  "author": "b-iurea",