pi-magi-theme 0.1.3 → 0.2.0

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.
@@ -2,24 +2,32 @@
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
- * - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) deliberate: they light up while the model thinks,
8
- * give the verdict when it answers, and form the /magi council (three real models voting)
9
- * - the GOLEM acts: it is animated by EMET ("truth") while tools run; a failing tool erases the aleph
10
- * and EMET becomes MET ("death"). SYNC is the golem's obedience: the tool success rate
8
+ * - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
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;
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
- * - the SEVEN SEALS measure the context window; compaction breaks the seventh seal and the world is remade
13
- * - llama-swap telemetry: VRAM, GPU load/temp/power, RAM, server-side tok/s, prompt tok/s, cache hits
14
- * - /magi config → assign a model to each MAGI (~/.pi/agent/magi.json)
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
19
+ * - llama-swap telemetry: VRAM, GPU load/temp/power, energy used, RAM, server-side tok/s, prompt tok/s, cache hits
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
 
26
+ import { execFile } from "node:child_process";
20
27
  import { readFileSync, writeFileSync } from "node:fs";
21
28
  import { homedir } from "node:os";
22
29
  import { join } from "node:path";
30
+ import { promisify } from "node:util";
23
31
  import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
24
32
  import { completeSimple } from "@earendil-works/pi-ai";
25
33
  import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
@@ -37,9 +45,30 @@ const MAGI_WORD = [
37
45
  "╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝",
38
46
  ];
39
47
 
40
- /** Tree of Life canvas, in terminal cells; drawn with braille dots (2×4 sub-pixels per cell). */
41
- const TREE_W = 30;
42
- 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
+ ];
43
72
 
44
73
  /** Sephirot in the order of the "lightning flash" (Keter → Malkuth), positioned in sub-pixels. */
45
74
  const SEPHIROT = [
@@ -55,82 +84,6 @@ const SEPHIROT = [
55
84
  { name: "MALKUTH", meaning: "the kingdom", x: 30, y: 75 },
56
85
  ] as const;
57
86
 
58
- /** Da'at, the hidden sephirah: drawn dashed, not part of the flash. */
59
- const DAAT = { x: 30, y: 28 };
60
-
61
- /** The 22 paths, as pairs of SEPHIROT indices. */
62
- const TREE_PATHS: readonly [number, number][] = [
63
- [0, 1], [0, 2], [0, 5], [1, 2], [1, 5], [1, 3], [2, 5], [2, 4], [3, 4], [3, 5], [3, 6],
64
- [4, 5], [4, 7], [5, 6], [5, 8], [5, 7], [6, 7], [6, 8], [6, 9], [7, 8], [7, 9], [8, 9],
65
- ];
66
-
67
- const BRAILLE_BITS = [
68
- [0x01, 0x08],
69
- [0x02, 0x10],
70
- [0x04, 0x20],
71
- [0x40, 0x80],
72
- ]; // [row][col] → dot bit
73
-
74
- /** Rasterizes the Tree of Life into braille: paths (muted), Da'at (dim, dashed), sephirot (accent rings). */
75
- function renderTreeOfLife(th: Theme): string[] {
76
- const W = TREE_W * 2;
77
- const H = TREE_H * 4;
78
- const PATH = 1;
79
- const DAAT_PX = 2;
80
- const NODE = 3;
81
- const px = new Uint8Array(W * H);
82
- const set = (x: number, y: number, v: number) => {
83
- const rx = Math.round(x);
84
- const ry = Math.round(y);
85
- if (rx >= 0 && ry >= 0 && rx < W && ry < H) px[ry * W + rx] = v;
86
- };
87
- for (const [a, b] of TREE_PATHS) {
88
- const p = SEPHIROT[a]!;
89
- const q = SEPHIROT[b]!;
90
- const n = Math.ceil(Math.max(Math.abs(q.x - p.x), Math.abs(q.y - p.y)));
91
- 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);
92
- }
93
- // ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
94
- // dashed rings keep every other 30° arc
95
- const ring = (cx: number, cy: number, r: number, v: number, dashed: boolean) => {
96
- for (let y = -r - 2; y <= r + 2; y++) {
97
- for (let x = -r - 2; x <= r + 2; x++) {
98
- const d = Math.hypot(x, y);
99
- if (d > r + 1.5) continue;
100
- const onRing = d > r - 0.7 && d <= r + 0.5;
101
- const arc = Math.floor((Math.atan2(y, x) + Math.PI) / (Math.PI / 6)) % 2 === 0;
102
- set(cx + x, cy + y, onRing && (!dashed || arc) ? v : 0);
103
- }
104
- }
105
- };
106
- ring(DAAT.x, DAAT.y, 3, DAAT_PX, true);
107
- for (const s of SEPHIROT) {
108
- ring(s.x, s.y, 3.5, NODE, false);
109
- set(s.x, s.y, NODE);
110
- }
111
-
112
- const lines: string[] = [];
113
- for (let row = 0; row < TREE_H; row++) {
114
- let line = "";
115
- for (let col = 0; col < TREE_W; col++) {
116
- let bits = 0;
117
- let top = 0;
118
- for (let dy = 0; dy < 4; dy++) {
119
- for (let dx = 0; dx < 2; dx++) {
120
- const v = px[(row * 4 + dy) * W + col * 2 + dx]!;
121
- if (!v) continue;
122
- bits |= BRAILLE_BITS[dy]![dx]!;
123
- top = Math.max(top, v);
124
- }
125
- }
126
- const ch = bits ? String.fromCharCode(0x2800 + bits) : " ";
127
- line += bits ? th.fg(top === NODE ? "accent" : top === DAAT_PX ? "dim" : "muted", ch) : ch;
128
- }
129
- lines.push(line);
130
- }
131
- return lines;
132
- }
133
-
134
87
  type NodeLight = "off" | "on" | "hot";
135
88
 
136
89
  /** The ten sephirot as a tiny path: ●━●━◉─○ … (hot = where the light is now). */
@@ -176,9 +129,169 @@ const GOLEM_FALLEN = [
176
129
  " ▀▀▀▀ ▀▀▀▀ ",
177
130
  ];
178
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
+
179
293
  const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
180
294
  type MagiUnit = (typeof MAGI_UNITS)[number];
181
- const MAGI_TASKS = ["ANALYSIS", "SYNTHESIS", "VERIFY"] as const;
182
295
 
183
296
  function pulseFrames(theme: Theme): string[] {
184
297
  return [
@@ -191,27 +304,52 @@ function pulseFrames(theme: Theme): string[] {
191
304
  ];
192
305
  }
193
306
 
307
+ /** Pseudo-random on/off per unit, changing every `hold` frames: the MAGI flicker while they work. */
308
+ function flicker(frame: number, unit: number, hold = 2): boolean {
309
+ const x = Math.imul(Math.floor(frame / hold) + 1, 2654435761) ^ Math.imul(unit + 1, 40503);
310
+ return (x >>> 0) % 100 < 55;
311
+ }
312
+
194
313
  /* ─────────────────────────────────────────────────────── live state ── */
195
314
 
196
315
  type Phase = "idle" | "thinking" | "responding" | "tool";
316
+ type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
197
317
 
198
318
  const state = {
199
319
  phase: "idle" as Phase,
200
320
  phaseSince: Date.now(),
201
321
  toolName: "",
322
+ toolTarget: "", // file or command the current tool works on
202
323
  turns: 0,
203
324
  toolOk: 0, // CHESED
204
325
  toolFail: 0, // GEBURAH
205
326
  lastFailAt: 0,
206
327
  lastFailTool: "",
328
+ lastFailTarget: "",
207
329
  runStart: 0,
208
330
  lastRunMs: 0,
209
331
  compacting: false,
210
332
  compactSince: 0,
211
333
  compactBy: "", // who opens the seals: "smart-compact" (pi-smart-compact package) or "pi native"
212
334
  rebornAt: 0,
335
+ hasSmartCompact: false,
336
+ lastCouncil: undefined as { verdict: Vote | null; tally: number; question: string } | undefined,
337
+ };
338
+
339
+ /** Panel preferences, persisted under "ui" in ~/.pi/agent/magi.json. */
340
+ type Currency = "EUR" | "USD";
341
+
342
+ const ui = {
343
+ compact: false,
344
+ kwhPrice: undefined as number | undefined, // price per kWh, for the COST row (/magi-ui config)
345
+ currency: "EUR" as Currency,
213
346
  };
214
347
 
348
+ function fmtMoney(value: number): string {
349
+ const digits = value < 1 ? 3 : 2;
350
+ return ui.currency === "USD" ? `$${value.toFixed(digits)}` : `${value.toFixed(digits)} €`;
351
+ }
352
+
215
353
  function setPhase(p: Phase): void {
216
354
  if (state.phase === p) return;
217
355
  state.phase = p;
@@ -221,6 +359,8 @@ function setPhase(p: Phase): void {
221
359
  const ANIM_STEP_MS = 220;
222
360
  const FAIL_FLASH_MS = 2500;
223
361
  const REBIRTH_MS = 6000;
362
+ const DONE_TITLE_AFTER_MS = 30_000;
363
+ const SIXTH_SEAL_PERCENT = (6 / 7) * 100;
224
364
 
225
365
  /** Golem obedience: share of tool calls that succeeded (null before the first tool). */
226
366
  function syncPercent(): number | null {
@@ -234,12 +374,24 @@ function animating(now = Date.now()): boolean {
234
374
  state.phase !== "idle" ||
235
375
  swap.state === "checking" ||
236
376
  swap.state === "loading" ||
377
+ (swap.state === "ready" && now - swap.since < ONLINE_MS) ||
237
378
  state.compacting ||
238
379
  now - state.rebornAt < REBIRTH_MS ||
239
380
  now - state.lastFailAt < FAIL_FLASH_MS
240
381
  );
241
382
  }
242
383
 
384
+ /** What a tool works on, from its arguments: a path, a command, a pattern, or the first short string. */
385
+ function toolTarget(args: unknown): string {
386
+ if (!args || typeof args !== "object") return "";
387
+ const a = args as Record<string, unknown>;
388
+ for (const key of ["path", "file_path", "filePath", "command", "cmd", "pattern", "query", "url"]) {
389
+ if (typeof a[key] === "string" && a[key]) return String(a[key]).replace(/\s+/g, " ").trim();
390
+ }
391
+ const first = Object.values(a).find((v) => typeof v === "string" && v.length > 0 && v.length < 200);
392
+ return typeof first === "string" ? first.replace(/\s+/g, " ").trim() : "";
393
+ }
394
+
243
395
  /** Performance of the current stream / last message. */
244
396
  const perf = {
245
397
  start: 0, // message_start
@@ -270,6 +422,8 @@ const sessionStart = Date.now();
270
422
 
271
423
  let liveCtx: ExtensionContext | undefined;
272
424
 
425
+ /* ── token totals: counted once per message instead of rescanning the whole session ── */
426
+
273
427
  interface TokenStats {
274
428
  input: number;
275
429
  output: number;
@@ -277,24 +431,26 @@ interface TokenStats {
277
431
  cost: number;
278
432
  }
279
433
 
280
- let statsCache: { at: number; value: TokenStats } = { at: 0, value: { input: 0, output: 0, cacheRead: 0, cost: 0 } };
281
-
282
- function tokenStats(): TokenStats {
283
- const now = Date.now();
284
- if (now - statsCache.at < 500) return statsCache.value;
285
- const value: TokenStats = { input: 0, output: 0, cacheRead: 0, cost: 0 };
286
- const branch = liveCtx?.sessionManager?.getBranch?.() ?? [];
287
- for (const entry of branch) {
288
- if (entry.type === "message" && entry.message.role === "assistant") {
289
- const m = entry.message as AssistantMessage;
290
- value.input += m.usage.input;
291
- value.output += m.usage.output;
292
- value.cacheRead += m.usage.cacheRead ?? 0;
293
- value.cost += m.usage.cost.total;
434
+ const tokens: TokenStats = { input: 0, output: 0, cacheRead: 0, cost: 0 };
435
+
436
+ function addUsage(m: AssistantMessage): void {
437
+ tokens.input += m.usage?.input ?? 0;
438
+ tokens.output += m.usage?.output ?? 0;
439
+ tokens.cacheRead += m.usage?.cacheRead ?? 0;
440
+ tokens.cost += m.usage?.cost?.total ?? 0;
441
+ }
442
+
443
+ /** Full recount: on session start and when switching branch. Also restores the last council verdict. */
444
+ function recountSession(ctx: ExtensionContext): void {
445
+ Object.assign(tokens, { input: 0, output: 0, cacheRead: 0, cost: 0 });
446
+ state.lastCouncil = undefined;
447
+ for (const entry of ctx.sessionManager.getBranch()) {
448
+ if (entry.type === "message" && entry.message.role === "assistant") addUsage(entry.message as AssistantMessage);
449
+ if (entry.type === "custom" && entry.customType === "magi-verdict") {
450
+ const d = entry.data as { verdict?: Vote | null; tally?: number; question?: string } | undefined;
451
+ if (d && Array.isArray((d as any).opinions)) state.lastCouncil = { verdict: d.verdict ?? null, tally: d.tally ?? 0, question: d.question ?? "" };
294
452
  }
295
453
  }
296
- statsCache = { at: now, value };
297
- return value;
298
454
  }
299
455
 
300
456
  function fmtTokens(n: number): string {
@@ -322,11 +478,17 @@ function bar(theme: Theme, filled: number, total: number, tone: ThemeColor): str
322
478
  return theme.fg(tone, "▓".repeat(f)) + theme.fg("dim", "░".repeat(total - f));
323
479
  }
324
480
 
325
- /** The seven seals of the context window: one breaks every 1/7 of it. */
326
- function renderSeals(theme: Theme, percent: number): string {
481
+ /** The seven seals of the context window: one breaks every 1/7 of it; the sixth blinks as a warning. */
482
+ function renderSeals(theme: Theme, percent: number, now = Date.now()): string {
327
483
  const broken = Math.min(7, Math.floor((percent / 100) * 7));
328
484
  const tone = usageTone(percent);
329
- return theme.fg(tone, "◉".repeat(broken)) + theme.fg("dim", "○".repeat(7 - broken));
485
+ let out = "";
486
+ for (let i = 0; i < 7; i++) {
487
+ if (i >= broken) out += theme.fg("dim", "○");
488
+ else if (broken === 6 && i === 5) out += theme.fg(Math.floor(now / 500) % 2 ? "warning" : "error", "◉");
489
+ else out += theme.fg(tone, "◉");
490
+ }
491
+ return out;
330
492
  }
331
493
 
332
494
  /* ──────────────────────────────────────────────────── MAGI triangle ── */
@@ -340,74 +502,193 @@ interface UnitView {
340
502
 
341
503
  const MAGI_DIAGRAM_WIDTH = 32;
342
504
 
505
+ /* ── MAGI screen: pixel art drawn with half blocks, two pixels per cell (one above the other) ── */
506
+
343
507
  /**
344
- * The three MAGI wired in a triangle: BALTHASAR on top, CASPAR bottom-left, MELCHIOR bottom-right,
345
- * 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.
346
510
  */
347
- function magiDiagram(th: Theme, top: UnitView, left: UnitView, right: UnitView, link: ThemeColor, hub: string): string[] {
348
- const center = (s: string) => {
349
- const w = visibleWidth(s);
350
- const l = Math.max(0, Math.floor((10 - w) / 2));
351
- 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 },
352
594
  };
353
- const edge = (u: UnitView, s: string) => th.fg(u.lit ? u.tone : "dim", s);
354
- const name = (u: UnitView) => (u.lit ? th.bold(th.fg(u.tone, center(u.name))) : th.fg("muted", center(u.name)));
355
- const stat = (u: UnitView) => th.fg(u.lit ? u.tone : "dim", center(u.status));
356
- const L = (s: string) => th.fg(link, s);
357
- 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
+ }
358
631
 
359
- return [
360
- sp(10) + edge(top, "┌──────────┐") + sp(10),
361
- sp(6) + L("╭───") + edge(top, "┤") + name(top) + edge(top, "├") + L("───╮") + sp(6),
362
- sp(6) + L("│") + sp(3) + edge(top, "│") + stat(top) + edge(top, "│") + sp(3) + L("│") + sp(6),
363
- sp(6) + L("│") + sp(3) + edge(top, "└──────────┘") + sp(3) + L("│") + sp(6),
364
- " " + edge(left, "┌────") + L("┴") + edge(left, "─────┐") + sp(6) + edge(right, "┌─────") + L("┴") + edge(right, "────┐") + " ",
365
- " " + edge(left, "│") + name(left) + edge(left, "│") + L(hub) + edge(right, "│") + name(right) + edge(right, "│") + " ",
366
- " " + edge(left, "│") + stat(left) + edge(left, "│") + sp(6) + edge(right, "│") + stat(right) + edge(right, "│") + " ",
367
- " " + 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) },
368
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
+ );
369
661
  }
370
662
 
371
663
  /* ─────────────────────────────────────────────────────────── header ── */
372
664
 
373
665
  /** Static header: it scrolls away with the conversation, so nothing here animates. */
374
666
  function buildHeader(theme: Theme) {
375
- const tree = renderTreeOfLife(theme);
376
-
377
667
  return {
378
668
  render(width: number): string[] {
379
669
  const orange = (s: string) => theme.fg("accent", s);
380
- const dim = (s: string) => theme.fg("dim", s);
381
- const muted = (s: string) => theme.fg("muted", s);
382
- const triad = MAGI_UNITS.map((u) => theme.fg("success", u)).join(dim(" · "));
383
- const subtitle = "KETER → MALKUTH · 10 SEPHIROT · 22 PATHS";
384
- const lore = "THE MAGI JUDGE · THE GOLEM ACTS · THE SEALS KEEP TIME";
385
-
386
- // Narrow layout: a single identification line.
387
- if (width < 44) {
388
- return ["", orange(theme.bold("◆ MAGI")) + dim(" // ") + triad, ""].map((l) => truncateToWidth(l, width));
389
- }
390
-
391
- // Medium layout: tree on top, wordmark below.
392
- if (width < 70) {
393
- const lines = ["", ...tree, ""];
394
- for (const l of MAGI_WORD) lines.push(orange(l));
395
- lines.push(dim("├─ ") + triad + dim(" ─┤"), muted(subtitle), dim(lore), "");
396
- return lines.map((l) => truncateToWidth(l, width));
397
- }
398
-
399
- // Wide layout: tree left, wordmark right.
400
- const lines = [""];
401
- for (let i = 0; i < tree.length; i++) {
402
- let right = "";
403
- if (i >= 5 && i <= 10) right = orange(MAGI_WORD[i - 5]!);
404
- else if (i === 12) right = dim("├─ ") + triad + dim(" ─┤");
405
- else if (i === 13) right = muted(subtitle);
406
- else if (i === 14) right = dim(lore);
407
- 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"))];
408
690
  }
409
- lines.push("");
410
- return lines;
691
+ return ["", ...lines, note, ""].map((l) => truncateToWidth(l, width));
411
692
  },
412
693
  invalidate() {},
413
694
  };
@@ -424,16 +705,19 @@ interface GpuStat {
424
705
  power: number;
425
706
  }
426
707
 
427
- type SwapState = "off" | "checking" | "loading" | "ready" | "error";
708
+ type SwapState = "off" | "checking" | "loading" | "ready" | "asleep" | "error";
428
709
 
429
710
  /** Live state of the llama-swap server behind the session model (only when the provider is "llama-swap"). */
430
711
  const swap = {
431
712
  base: "", // e.g. http://host:9292
432
713
  headers: {} as Record<string, string>,
433
- modelId: "",
714
+ modelId: "", // the id pi uses (may be an alias)
715
+ realId: "", // the model llama-swap actually runs for that id
434
716
  state: "off" as SwapState,
435
717
  since: 0, // when the current state started
436
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)
437
721
  error: "",
438
722
  gpus: [] as GpuStat[],
439
723
  ramUsed: 0,
@@ -442,6 +726,9 @@ const swap = {
442
726
  srvPps: 0, // server-measured prompt processing tok/s
443
727
  cacheTokens: 0,
444
728
  inputTokens: 0,
729
+ energyWh: 0, // GPU energy since the session started
730
+ lastSampleAt: 0,
731
+ lastWatts: 0,
445
732
  };
446
733
 
447
734
  function setSwapState(s: SwapState, error = ""): void {
@@ -478,20 +765,46 @@ function parseSwapMetrics(text: string): void {
478
765
  swap.gpus = [...gpus.entries()].sort(([a], [b]) => Number(a) - Number(b)).map(([, g]) => g);
479
766
  }
480
767
 
768
+ /**
769
+ * Integrates GPU power over time into Wh.
770
+ * ponytail: trapezoid over the 3–30s poll samples, GPUs only; gaps over 60s are clamped.
771
+ */
772
+ function sampleEnergy(now = Date.now()): void {
773
+ const watts = swap.gpus.reduce((sum, g) => sum + g.power, 0);
774
+ if (swap.lastSampleAt) {
775
+ const dtH = Math.min(60_000, now - swap.lastSampleAt) / 3_600_000;
776
+ swap.energyWh += ((swap.lastWatts + watts) / 2) * dtH;
777
+ }
778
+ swap.lastSampleAt = now;
779
+ swap.lastWatts = watts;
780
+ }
781
+
481
782
  async function refreshSwapMetrics(): Promise<void> {
482
783
  if (!swap.base) return;
483
784
  try {
484
785
  parseSwapMetrics(await (await swapGet("/metrics")).text());
786
+ sampleEnergy();
485
787
  } catch {
486
788
  // server unreachable: keep the last values
487
789
  }
488
790
  }
489
791
 
490
- /** Server-side token metrics of the most recent request (/api/metrics/activity, newest first). */
792
+ /** Notices when llama-swap unloaded the session model (e.g. its ttl expired) so the panel can say so. */
793
+ async function refreshSwapRunning(): Promise<void> {
794
+ if (!swap.base || swap.state !== "ready") return;
795
+ try {
796
+ const { running } = (await (await swapGet("/running")).json()) as { running?: { model: string }[] };
797
+ if (!(running ?? []).some((r) => r.model === swap.realId)) setSwapState("asleep");
798
+ } catch {
799
+ // optional
800
+ }
801
+ }
802
+
803
+ /** Server-side token metrics of the most recent request: a single row from /api/metrics/activity. */
491
804
  async function refreshSwapActivity(): Promise<void> {
492
805
  if (!swap.base) return;
493
806
  try {
494
- const { data } = (await (await swapGet("/api/metrics/activity")).json()) as { data?: any[] };
807
+ const { data } = (await (await swapGet("/api/metrics/activity?limit=1")).json()) as { data?: any[] };
495
808
  const t = data?.[0]?.tokens;
496
809
  if (!t) return;
497
810
  if (t.tokens_per_second > 0) swap.srvTps = t.tokens_per_second;
@@ -503,18 +816,74 @@ async function refreshSwapActivity(): Promise<void> {
503
816
  }
504
817
  }
505
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
+
506
824
  /**
507
- * Makes sure the session model is in VRAM. Any request under /upstream/<model>/ makes llama-swap
508
- * 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.
509
828
  */
510
- 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> {
511
879
  if (!model || model.provider !== "llama-swap" || !model.baseUrl) {
512
880
  swap.base = "";
513
881
  setSwapState("off");
514
- return;
882
+ return false;
515
883
  }
516
884
  const id = model.id;
517
885
  swap.modelId = id;
886
+ swap.realId = id;
518
887
  setSwapState("checking");
519
888
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
520
889
  swap.base = model.baseUrl.replace(/\/v1\/?$/, "");
@@ -524,6 +893,27 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
524
893
  };
525
894
  void refreshSwapMetrics();
526
895
  void refreshSwapActivity();
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;
527
917
 
528
918
  // no answer within 400ms → the model isn't in VRAM and is being loaded
529
919
  const slow = setTimeout(() => {
@@ -534,8 +924,15 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
534
924
  const res = await swapGet(`/upstream/${encodeURIComponent(id)}/health`, 15 * 60_000);
535
925
  if (swap.modelId !== id) return; // the model changed meanwhile
536
926
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
537
- 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;
538
934
  setSwapState("ready");
935
+ if (!ctx.sessionManager.getBranch().some((e) => e.type === "message")) void prewarmPrefix(ctx.cwd);
539
936
  } catch (err) {
540
937
  if (swap.modelId === id) setSwapState("error", err instanceof Error ? err.message : String(err));
541
938
  } finally {
@@ -544,18 +941,158 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
544
941
  }
545
942
  }
546
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
+
1015
+ /* ── /magi-ui status: a report built from the last requests llama-swap recorded ── */
1016
+
1017
+ interface ActivityRow {
1018
+ timestamp: string;
1019
+ model: string;
1020
+ resp_status_code: number;
1021
+ duration_ms: number;
1022
+ tokens?: {
1023
+ cache_tokens?: number;
1024
+ input_tokens?: number;
1025
+ output_tokens?: number;
1026
+ prompt_per_second?: number;
1027
+ tokens_per_second?: number;
1028
+ draft_tokens?: number;
1029
+ draft_acc_tokens?: number;
1030
+ };
1031
+ }
1032
+
1033
+ const ACTIVITY_REPORT_ROWS = 100;
1034
+
1035
+ function activityReport(th: Theme, rows: ActivityRow[], total: number, now = Date.now()): string[] {
1036
+ const dim = (s: string) => th.fg("dim", s);
1037
+ const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
1038
+ const ago = (iso: string) => {
1039
+ const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000));
1040
+ return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`;
1041
+ };
1042
+ const out = [th.fg("accent", th.bold("LLAMA-SWAP STATUS")) + dim(` · last ${rows.length} of ${total} requests`)];
1043
+ const byModel = new Map<string, ActivityRow[]>();
1044
+ for (const r of rows) byModel.set(r.model, [...(byModel.get(r.model) ?? []), r]);
1045
+
1046
+ for (const [model, rs] of byModel) {
1047
+ const t = rs.map((r) => r.tokens ?? {});
1048
+ const sum = (k: keyof NonNullable<ActivityRow["tokens"]>) => t.reduce((a, x) => a + Math.max(0, x[k] ?? 0), 0);
1049
+ const errors = rs.filter((r) => r.resp_status_code >= 400).length;
1050
+ const gen = mean(t.map((x) => x.tokens_per_second ?? 0).filter((v) => v > 0));
1051
+ const prompt = mean(t.map((x) => x.prompt_per_second ?? 0).filter((v) => v > 0));
1052
+ const cached = sum("cache_tokens");
1053
+ const input = sum("input_tokens");
1054
+ const draft = sum("draft_tokens");
1055
+ const accepted = sum("draft_acc_tokens");
1056
+ const durations = rs.map((r) => r.duration_ms).sort((a, b) => a - b);
1057
+ const p95 = durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))] ?? 0;
1058
+ out.push("", th.fg("text", th.bold(model)));
1059
+ out.push(dim(" requests ") + th.fg("text", String(rs.length)) + dim(" · errors ") + th.fg(errors ? "error" : "success", String(errors)) + dim(` · last ${ago(rs[0]!.timestamp)}`));
1060
+ out.push(
1061
+ dim(" speed ") +
1062
+ th.fg("success", `gen ${gen.toFixed(1)} tok/s`) +
1063
+ dim(" · ") +
1064
+ th.fg("text", `prompt ${prompt.toFixed(1)} tok/s`) +
1065
+ (draft ? dim(" · draft accepted ") + th.fg("text", `${Math.round((accepted / draft) * 100)}%`) : ""),
1066
+ );
1067
+ out.push(
1068
+ dim(" tokens ") +
1069
+ th.fg("text", `in ${fmtTokens(input)} · cached ${fmtTokens(cached)}`) +
1070
+ dim(` (hit ${cached + input ? Math.round((cached / (cached + input)) * 100) : 0}%)`) +
1071
+ th.fg("text", ` · out ${fmtTokens(sum("output_tokens"))}`),
1072
+ );
1073
+ out.push(dim(" duration ") + th.fg("text", `avg ${fmtMs(mean(durations))} · p95 ${fmtMs(p95)} · max ${fmtMs(durations.at(-1) ?? 0)}`));
1074
+ }
1075
+ const slowest = [...rows].sort((a, b) => b.duration_ms - a.duration_ms)[0];
1076
+ if (slowest) {
1077
+ const st = slowest.tokens ?? {};
1078
+ out.push("", dim("slowest ") + th.fg("warning", fmtMs(slowest.duration_ms)) + dim(` · ${slowest.model} · ${ago(slowest.timestamp)} · in ${fmtTokens((st.input_tokens ?? 0) + (st.cache_tokens ?? 0))} out ${fmtTokens(st.output_tokens ?? 0)}`));
1079
+ }
1080
+ return out;
1081
+ }
1082
+
547
1083
  /* ─────────────────────────────────────────────────────── side panel ── */
548
1084
 
549
1085
  const PANEL_WIDTH = MAGI_DIAGRAM_WIDTH + 2;
550
1086
 
551
- /** Frames per deliberation cycle while thinking: units light up one by one, then consensus. */
552
- const CYCLE = 32;
553
-
554
1087
  class MagiPanel implements Component {
555
1088
  private frame = 0;
556
1089
  private timer: ReturnType<typeof setInterval> | null = null;
1090
+ /** The data rows change slowly: they are rebuilt at most twice a second, only the council animates every frame. */
1091
+ private cache: { at: number; inner: number; compact: boolean; lines: string[] } | undefined;
557
1092
  /** git branch, provided by the footer (the only place pi exposes it). */
558
1093
  branch?: () => string | null | undefined;
1094
+ /** When set (fullscreen column), the panel pads itself down to this many rows. */
1095
+ fillHeight?: () => number;
559
1096
 
560
1097
  constructor(
561
1098
  private readonly tui: TUI,
@@ -568,7 +1105,9 @@ class MagiPanel implements Component {
568
1105
  }, 125);
569
1106
  }
570
1107
 
571
- invalidate(): void {}
1108
+ invalidate(): void {
1109
+ this.cache = undefined;
1110
+ }
572
1111
 
573
1112
  dispose(): void {
574
1113
  if (this.timer) clearInterval(this.timer);
@@ -588,12 +1127,19 @@ class MagiPanel implements Component {
588
1127
  return b("│") + this.pad(content, inner) + b("│");
589
1128
  }
590
1129
 
591
- private sep(inner: number, label?: string): string {
1130
+ private labelLine(inner: number, label: string, left: string, right: string, tone: ThemeColor): string {
592
1131
  const b = (s: string) => this.theme.fg("border", s);
593
- if (!label) return b("├" + "─".repeat(inner) + "┤");
594
1132
  const text = ` ${truncateToWidth(label, inner - 4)} `;
595
1133
  const rest = Math.max(0, inner - visibleWidth(text) - 1);
596
- return b("├─") + this.theme.fg("muted", text) + b("─".repeat(rest) + "┤");
1134
+ return b(left + "─") + this.theme.fg(tone, text) + b("─".repeat(rest) + right);
1135
+ }
1136
+
1137
+ private sep(inner: number, label: string): string {
1138
+ return this.labelLine(inner, label, "├", "┤", "muted");
1139
+ }
1140
+
1141
+ private centered(content: string, inner: number): string {
1142
+ return this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(content)) / 2))) + content, inner);
597
1143
  }
598
1144
 
599
1145
  private field(label: string, value: string, inner: number, tone: ThemeColor = "text"): string {
@@ -617,151 +1163,117 @@ class MagiPanel implements Component {
617
1163
  });
618
1164
  }
619
1165
 
620
- /* ── MAGI triangle (thinking, verdicts, boot) or the golem (tools) ── */
1166
+ /* ── top of the panel: the MAGI (flickering while working) or the golem (tools) ── */
621
1167
 
622
1168
  private councilSection(inner: number): string[] {
623
1169
  const th = this.theme;
624
1170
  const f = this.frame;
625
1171
  const now = Date.now();
1172
+ const secs = (t: number) => `${secsSince(t, now)}s`;
1173
+ const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
1174
+
1175
+ const idle = state.phase === "idle";
1176
+ const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
626
1177
  let title: string;
1178
+ let titleTone: ThemeColor = "accent";
627
1179
  let body: string[];
1180
+ let status: string;
628
1181
 
629
- const idle = state.phase === "idle";
630
- const booting = idle && (swap.state === "checking" || swap.state === "loading");
631
- const offline = idle && swap.state === "error";
632
- const golem = state.phase === "tool" || (idle && now - state.lastFailAt < FAIL_FLASH_MS);
633
-
634
- if (golem) {
635
- const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
636
- title = fallen
637
- ? `GOLEM FELL · MET · ${state.lastFailTool}`
638
- : `GOLEM · EMET · ${state.toolName || "tool"} ${secsSince(state.phaseSince, now)}s`;
1182
+ if (state.phase === "tool" || (idle && fallen)) {
1183
+ title = fallen ? `GOLEM FELL · MET · ${state.lastFailTool}` : `GOLEM · ${state.toolName || "tool"} · ${secs(state.phaseSince)}`;
1184
+ titleTone = fallen ? "error" : "warning";
639
1185
  body = this.golemArt(inner);
1186
+ const target = (fallen ? state.lastFailTarget || state.lastFailTool : state.toolTarget || state.toolName) || "tool";
1187
+ status = `${pulse} ${th.fg(fallen ? "error" : "text", truncateToWidth(target, inner - 4))}`;
640
1188
  } else {
641
- let link: ThemeColor;
642
- let hub: string;
643
- let units: UnitView[];
644
- const names = [...MAGI_UNITS];
645
-
646
- if (state.compacting || now - state.rebornAt < REBIRTH_MS) {
647
- // the seals break while the context is compacted; then the world is remade
648
- const reborn = !state.compacting;
649
- title = reborn
650
- ? "SEVENTH SEAL OPENED · REBORN"
651
- : `SEALS · ${state.compactBy || "compacting"} ${secsSince(state.compactSince, now)}s`;
652
- link = reborn ? "success" : f % 4 < 2 ? "warning" : "error";
653
- hub = reborn ? "═MAGI═" : ["─SEAL─", "━SEAL━"][f % 2]!;
654
- units = names.map((name, i) =>
655
- reborn
656
- ? { name, status: "REBORN", tone: "success", lit: true }
657
- : { name, status: "SEAL " + "◉".repeat(1 + ((Math.floor(f / 4) + i) % 3)), tone: "warning", lit: (f + i) % 3 !== 0 },
658
- );
659
- } else if (booting) {
660
- // MAGI boot while llama-swap loads the model into VRAM: units come online one at a time.
661
- const litCount = Math.floor(f / 6) % 4;
662
- title = swap.state === "loading" ? `BOOT · LOADING MODEL ${secsSince(swap.since, now)}s` : "BOOT · CHECKING MODEL";
663
- link = f % 4 < 2 ? "warning" : "dim";
664
- hub = ["─MAGI─", "━MAGI━"][f % 2]!;
665
- units = names.map((name, i) =>
666
- i < litCount
667
- ? { name, status: "ONLINE", tone: "warning", lit: true }
668
- : { name, status: i === litCount ? "BOOT" : "·····", tone: "warning", lit: i === litCount && f % 2 === 0 },
669
- );
670
- } else if (offline) {
1189
+ let word: string;
1190
+ let tone: ThemeColor;
1191
+ let hub = "─MAGI─";
1192
+ let lit = (i: number) => flicker(f, i);
1193
+ let virus: Virus | undefined;
1194
+ const breathing = ["─MAGI─", "━MAGI━"][f % 2]!;
1195
+
1196
+ if (state.compacting) {
1197
+ title = `COMPACTING · ${state.compactBy || "context"} · ${secs(state.compactSince)}`;
1198
+ [word, tone, hub, titleTone] = ["COMPACTING", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
1199
+ } else if (now - state.rebornAt < REBIRTH_MS) {
1200
+ title = "SEVENTH SEAL OPENED";
1201
+ [word, tone, hub, titleTone] = ["REBORN", "success", "═MAGI═", "success"];
1202
+ lit = () => true;
1203
+ } else if (idle && (swap.state === "checking" || swap.state === "loading")) {
1204
+ title = swap.state === "loading" ? `LOADING MODEL · ${secs(swap.since)}` : "CHECKING MODEL";
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 };
1209
+ } else if (idle && swap.state === "error") {
671
1210
  title = "MAGI OFFLINE";
672
- link = "error";
673
- hub = "─ ╳ ──";
674
- units = names.map((name) => ({ name, status: "OFFLINE", tone: "error", lit: f % 16 < 8 }));
675
- } else if (idle && swap.state === "ready" && now - swap.since < 3000) {
1211
+ [word, tone, hub, titleTone] = ["OFFLINE", "error", "─ ╳ ──", "error"];
1212
+ lit = () => f % 16 < 8;
1213
+ } else if (idle && swap.state === "asleep") {
1214
+ title = "MODEL ASLEEP · type to wake";
1215
+ [word, tone, titleTone] = ["ASLEEP", "muted", "muted"];
1216
+ lit = () => false;
1217
+ } else if (idle && swap.state === "ready" && now - swap.since < ONLINE_MS) {
676
1218
  title = swap.loadMs ? `MAGI ONLINE · ${fmtMs(swap.loadMs)}` : "MAGI ONLINE";
677
- link = "success";
678
- hub = "═MAGI═";
679
- units = names.map((name) => ({ name, status: "ONLINE", tone: "success", lit: true }));
1219
+ [word, tone, hub, titleTone] = ["ONLINE", "accent", "═MAGI═", "success"];
1220
+ lit = () => true;
1221
+ virus = { infected: swap.infection, recovered: (now - swap.since) / RECOVER_MS, blink: f % 4 < 2 };
680
1222
  } else if (state.phase === "thinking") {
681
- // Deliberation: each unit lights up in turn until all three agree, then the cycle restarts.
682
- const litCount = Math.min(3, Math.floor((f % CYCLE) / 8));
683
- const consensus = litCount === 3;
684
- title = consensus ? "CONSENSUS" : `DELIBERATION ${secsSince(state.phaseSince, now)}s`;
685
- link = consensus ? (f % 4 < 2 ? "warning" : "accent") : "accent";
686
- const dots = ["· ", " · ", " · ", " · ", " ·"];
687
- units = names.map((name, i) => {
688
- if (i < litCount) return { name, status: consensus ? "AGREE" : MAGI_TASKS[i]!, tone: consensus ? "warning" : "accent", lit: true };
689
- // the unit being processed right now flickers
690
- return { name, status: dots[(f + i) % dots.length]!, tone: "accent", lit: i === litCount && f % 2 === 0 };
691
- });
692
- hub = consensus ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][f % 2]!;
1223
+ title = `THINKING · ${secs(state.phaseSince)}`;
1224
+ [word, tone, hub] = ["THINKING", "accent", breathing];
693
1225
  } else if (state.phase === "responding") {
694
- title = "VERDICT: APPROVED";
695
- link = "success";
696
- hub = "═MAGI═";
697
- units = names.map((name) => ({ name, status: "APPROVE", tone: "success", lit: true }));
1226
+ const tps = liveTps();
1227
+ title = `RESPONDING${tps ? ` · ${tps.toFixed(1)} tok/s` : ""}`;
1228
+ [word, tone, hub, titleTone] = ["RESPONDING", "success", breathing, "success"];
698
1229
  } else {
699
- title = "MAGI · AWAITING QUESTION";
700
- link = "dim";
701
- hub = "─MAGI─";
702
- units = names.map((name) => ({ name, status: "STANDBY", tone: "success", lit: false }));
1230
+ title = "STANDBY";
1231
+ [word, tone, titleTone] = ["STANDBY", "muted", "muted"];
1232
+ lit = () => false;
703
1233
  }
704
- body = magiDiagram(th, units[1]!, units[2]!, units[0]!, link, hub);
705
- }
706
1234
 
707
- const out = [this.sep(inner, title)];
708
- for (const l of body) out.push(this.frameLine(l, inner));
1235
+ const units = MAGI_UNITS.map((name, i) => ({ name, status: word, tone, lit: lit(i) }));
1236
+ body = magiDiagram(th, units[1]!, units[2]!, units[0]!, hub, virus);
1237
+ const c = state.lastCouncil;
1238
+ status = c
1239
+ ? `${pulse} ${th.fg("dim", "COUNCIL ")}${th.fg(voteTone(c.verdict), `${c.verdict ?? "NO QUORUM"} ${c.tally}/3`)}`
1240
+ : `${pulse} ${th.fg("dim", "COUNCIL · /magi")}`;
1241
+ }
709
1242
 
710
- // status row: what the agent is doing
711
- const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
712
- let activity: string;
713
- if (state.compacting) activity = th.fg("warning", "COMPACTING");
714
- else if (state.phase === "tool") activity = th.fg("warning", "TOOL CALL");
715
- else if (state.phase === "thinking") activity = th.fg("accent", "THINKING");
716
- else if (state.phase === "responding") activity = th.fg("success", "RESPONDING");
717
- else if (booting) activity = th.fg("warning", "LOADING MODEL");
718
- else if (offline) activity = th.fg("error", "SERVER OFFLINE");
719
- else activity = th.fg("dim", "STANDBY");
720
- const status = `${pulse} ${activity}`;
721
- out.push(this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(status)) / 2))) + status, inner));
722
- return out;
1243
+ return [this.labelLine(inner, title, "┌", "┐", titleTone), ...body.map((l) => this.frameLine(l, inner)), this.centered(status, inner)];
723
1244
  }
724
1245
 
725
- render(width: number): string[] {
726
- const th = this.theme;
727
- const inner = Math.max(24, width - 2);
728
- const b = (s: string) => th.fg("border", s);
729
- const out: string[] = [];
730
-
731
- const title = " MAGI // SESSION MONITOR ";
732
- const fill = Math.max(0, inner - visibleWidth(title) - 1);
733
- out.push(b("┌─") + th.fg("accent", title) + b("─".repeat(fill) + "┐"));
1246
+ /* ── data rows ── */
734
1247
 
735
- out.push(...this.councilSection(inner));
736
-
737
- // session data
738
- out.push(this.sep(inner, "SESSION"));
1248
+ private sessionRows(inner: number, compact: boolean): string[] {
1249
+ const out = [this.sep(inner, "SESSION")];
739
1250
  const model = liveCtx?.model;
740
1251
  out.push(this.field("MODEL", truncateToWidth(model?.id ?? "—", inner - 11), inner, "text"));
741
- out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
1252
+ if (!compact) out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
742
1253
  out.push(this.field("THINKING", liveCtx?.thinkingLevel ?? "off", inner, "muted"));
743
- const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
744
- out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
745
- const branch = this.branch?.();
746
- if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
747
-
748
- // telemetry
749
- out.push(this.sep(inner, "TELEMETRY"));
750
- const stats = tokenStats();
751
- out.push(
752
- this.frameLine(
753
- ` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns))}`,
754
- inner,
755
- ),
756
- );
757
- out.push(
758
- this.frameLine(
759
- ` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(stats.input))} ${th.fg("warning", "↓" + fmtTokens(stats.output))}`,
760
- inner,
761
- ),
762
- );
763
- if (stats.cacheRead) out.push(this.field("CACHE", fmtTokens(stats.cacheRead), inner, "muted"));
764
- if (stats.cost) out.push(this.field("COST", `$${stats.cost.toFixed(3)}`, inner, "muted"));
1254
+ if (!compact) {
1255
+ const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
1256
+ out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
1257
+ const branch = this.branch?.();
1258
+ if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
1259
+ }
1260
+ return out;
1261
+ }
1262
+
1263
+ private telemetryRows(inner: number, compact: boolean): string[] {
1264
+ const th = this.theme;
1265
+ const out = [this.sep(inner, "TELEMETRY")];
1266
+ if (!compact) {
1267
+ out.push(this.frameLine(` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns))}`, inner));
1268
+ out.push(
1269
+ this.frameLine(
1270
+ ` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(tokens.input))} ${th.fg("warning", "↓" + fmtTokens(tokens.output))}`,
1271
+ inner,
1272
+ ),
1273
+ );
1274
+ if (tokens.cacheRead) out.push(this.field("CACHE", fmtTokens(tokens.cacheRead), inner, "muted"));
1275
+ if (tokens.cost) out.push(this.field("API COST", `$${tokens.cost.toFixed(3)}`, inner, "muted"));
1276
+ }
765
1277
 
766
1278
  // SYNC: the golem's obedience = tool success rate (CHESED ✓ / GEBURAH ✗)
767
1279
  const sync = syncPercent();
@@ -782,15 +1294,19 @@ class MagiPanel implements Component {
782
1294
  );
783
1295
 
784
1296
  // SEALS: the context window, one seal per seventh
785
- const usage = liveCtx?.getContextUsage?.();
1297
+ const usage = contextUsage();
786
1298
  const percent = usage?.percent ?? 0;
787
- out.push(this.frameLine(` ${th.fg("dim", "SEALS".padEnd(9))}${renderSeals(th, percent)} ${th.fg(usageTone(percent), `${percent.toFixed(0)}%`)}`, inner));
788
- if (usage?.contextWindow) {
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));
1301
+ if (!compact && usage?.contextWindow) {
789
1302
  out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
790
1303
  }
1304
+ return out;
1305
+ }
791
1306
 
792
- // performance
793
- out.push(this.sep(inner, "PERFORMANCE"));
1307
+ private performanceRows(inner: number, compact: boolean): string[] {
1308
+ const th = this.theme;
1309
+ const out = [this.sep(inner, "PERFORMANCE")];
794
1310
  const live = state.phase !== "idle" && perf.chars > 0;
795
1311
  // idle: prefer the value measured by llama-swap itself
796
1312
  const tps = live ? liveTps() : swap.srvTps || perf.tps;
@@ -800,6 +1316,7 @@ class MagiPanel implements Component {
800
1316
  inner,
801
1317
  ),
802
1318
  );
1319
+ if (compact) return out;
803
1320
  if (swap.srvPps) out.push(this.field("PROMPT/S", swap.srvPps.toFixed(1), inner, "muted"));
804
1321
  // llama-swap's input_tokens are the prompt tokens processed outside the cache
805
1322
  const promptTotal = swap.cacheTokens + swap.inputTokens;
@@ -812,47 +1329,83 @@ class MagiPanel implements Component {
812
1329
  out.push(this.field("DURATION", perf.lastMs ? fmtMs(perf.lastMs) : "—", inner, "muted"));
813
1330
  const up = Math.floor((Date.now() - sessionStart) / 60000);
814
1331
  out.push(this.field("UPTIME", `${Math.floor(up / 60)}h ${String(up % 60).padStart(2, "0")}m`, inner, "muted"));
1332
+ return out;
1333
+ }
815
1334
 
816
- // llama-swap server
817
- if (swap.base) {
818
- out.push(this.sep(inner, "LLAMA-SWAP"));
819
- const st =
820
- swap.state === "ready"
821
- ? th.fg("success", "IN VRAM")
1335
+ /** COST: GPU energy used in the session × price per kWh set with /magi-ui config. */
1336
+ private costRow(inner: number): string {
1337
+ if (!swap.gpus.length) return this.field("COST", "—", inner, "muted");
1338
+ 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");
1340
+ }
1341
+
1342
+ private swapRows(inner: number, compact: boolean): string[] {
1343
+ if (!swap.base) return [];
1344
+ const th = this.theme;
1345
+ const out = [this.sep(inner, "LLAMA-SWAP")];
1346
+ const st =
1347
+ swap.state === "ready"
1348
+ ? th.fg("success", "IN VRAM")
1349
+ : swap.state === "asleep"
1350
+ ? th.fg("muted", "ASLEEP")
822
1351
  : swap.state === "error"
823
1352
  ? th.fg("error", "OFFLINE")
824
1353
  : th.fg("warning", swap.state === "loading" ? `LOADING ${secsSince(swap.since)}s` : "CHECKING");
825
- const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
826
- out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}`, inner));
827
- if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
828
- const gib = (n: number) => n / 2 ** 30;
829
- for (const [i, g] of swap.gpus.entries()) {
830
- const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
831
- out.push(
832
- this.frameLine(
833
- ` ${th.fg("dim", `VRAM${i}`.padEnd(9))}${bar(th, Math.round(pct / 10), 10, "accent")} ${th.fg("muted", `${gib(g.memUsed).toFixed(1)}/${Math.round(gib(g.memTotal))}G`)}`,
834
- inner,
835
- ),
836
- );
837
- out.push(
838
- this.frameLine(
839
- ` ${th.fg("dim", `GPU${i}`.padEnd(9))}${th.fg(g.util > 0 ? "accent" : "muted", `${Math.round(g.util)}%`.padEnd(6))}${th.fg(g.temp >= 80 ? "error" : "muted", `${Math.round(g.temp)}°C`.padEnd(7))}${th.fg("muted", `${Math.round(g.power)}W`)}`,
840
- inner,
841
- ),
842
- );
843
- }
844
- if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
1354
+ const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
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));
1362
+ if (compact) return [...out, this.costRow(inner)];
1363
+ if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
1364
+ const gib = (n: number) => n / 2 ** 30;
1365
+ for (const [i, g] of swap.gpus.entries()) {
1366
+ const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
1367
+ out.push(
1368
+ this.frameLine(
1369
+ ` ${th.fg("dim", `VRAM${i}`.padEnd(9))}${bar(th, Math.round(pct / 10), 10, "accent")} ${th.fg("muted", `${gib(g.memUsed).toFixed(1)}/${Math.round(gib(g.memTotal))}G`)}`,
1370
+ inner,
1371
+ ),
1372
+ );
1373
+ out.push(
1374
+ this.frameLine(
1375
+ ` ${th.fg("dim", `GPU${i}`.padEnd(9))}${th.fg(g.util > 0 ? "accent" : "muted", `${Math.round(g.util)}%`.padEnd(6))}${th.fg(g.temp >= 80 ? "error" : "muted", `${Math.round(g.temp)}°C`.padEnd(7))}${th.fg("muted", `${Math.round(g.power)}W`)}`,
1376
+ inner,
1377
+ ),
1378
+ );
845
1379
  }
1380
+ 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"));
1382
+ out.push(this.costRow(inner));
1383
+ return out;
1384
+ }
846
1385
 
847
- out.push(b("└" + "─".repeat(inner) + "┘"));
848
- return out.map((l) => truncateToWidth(l, width));
1386
+ render(width: number): string[] {
1387
+ const inner = Math.max(24, width - 2);
1388
+ const now = Date.now();
1389
+ if (!this.cache || this.cache.inner !== inner || this.cache.compact !== ui.compact || now - this.cache.at > 500) {
1390
+ const c = ui.compact;
1391
+ this.cache = {
1392
+ at: now,
1393
+ inner,
1394
+ compact: c,
1395
+ lines: [...this.sessionRows(inner, c), ...this.telemetryRows(inner, c), ...this.performanceRows(inner, c), ...this.swapRows(inner, c)],
1396
+ };
1397
+ }
1398
+ const bottom = this.theme.fg("border", "└" + "─".repeat(inner) + "┘");
1399
+ const lines = [...this.councilSection(inner), ...this.cache.lines];
1400
+ // fullscreen: keep the frame going down to the bottom of the terminal
1401
+ const target = (this.fillHeight?.() ?? 0) - 1;
1402
+ while (lines.length < target) lines.push(this.frameLine("", inner));
1403
+ return [...lines, bottom].map((l) => truncateToWidth(l, width));
849
1404
  }
850
1405
  }
851
1406
 
852
1407
  /* ─────────────────────────────────────────────── MAGI deliberation ── */
853
1408
 
854
- type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
855
-
856
1409
  function voteTone(v: string | null | undefined): "success" | "warning" | "error" {
857
1410
  return v === "APPROVE" ? "success" : v === "CONDITIONAL" ? "warning" : "error";
858
1411
  }
@@ -925,13 +1478,16 @@ interface Deliberation {
925
1478
  tally: number;
926
1479
  }
927
1480
 
928
- /* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, … } ── */
1481
+ /* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, …, "ui": { "compact": true, "kwhPrice": 0.3 } } ── */
929
1482
 
930
1483
  interface MagiUnitConfig {
931
1484
  model?: string;
932
1485
  thinking?: string;
933
1486
  }
934
- type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>>;
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
+ };
935
1491
 
936
1492
  const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
937
1493
 
@@ -943,6 +1499,10 @@ function loadMagiConfig(): MagiConfig {
943
1499
  }
944
1500
  }
945
1501
 
1502
+ function saveMagiConfig(cfg: MagiConfig): void {
1503
+ writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
1504
+ }
1505
+
946
1506
  function parseVote(text: string): Vote {
947
1507
  const m = /VOTE:\s*\**\s*(APPROVE|CONDITIONAL|REJECT)/i.exec(text);
948
1508
  return m ? (m[1]!.toUpperCase() as Vote) : "CONDITIONAL";
@@ -981,6 +1541,22 @@ function conversationExcerpt(ctx: ExtensionContext, maxChars = 6000): string {
981
1541
  return joined.length > maxChars ? "…" + joined.slice(-maxChars) : joined;
982
1542
  }
983
1543
 
1544
+ const REVIEW_MAX_CHARS = 24_000;
1545
+
1546
+ /** The pending changes for /magi review: tracked changes against HEAD plus the names of untracked files. */
1547
+ async function pendingChanges(cwd: string): Promise<{ diff: string; untracked: string[] }> {
1548
+ const git = (args: string[]) => promisify(execFile)("git", args, { cwd, maxBuffer: 32 * 1024 * 1024 }).then((r) => r.stdout);
1549
+ let diff: string;
1550
+ try {
1551
+ diff = await git(["diff", "HEAD"]);
1552
+ } catch {
1553
+ // no commits yet: staged + unstaged
1554
+ diff = (await git(["diff", "--cached"])) + (await git(["diff"]));
1555
+ }
1556
+ const untracked = (await git(["ls-files", "--others", "--exclude-standard"]).catch(() => "")).split("\n").filter(Boolean);
1557
+ return { diff, untracked };
1558
+ }
1559
+
984
1560
  /** Common function words per language, used to name the reply language explicitly. */
985
1561
  const LANGUAGE_HINTS: readonly [string, readonly string[]][] = [
986
1562
  ["Italian", ["il", "lo", "la", "gli", "di", "che", "per", "non", "una", "con", "sono", "come", "perché", "è", "dovrei", "meglio", "mettiamo", "questo", "quale"]],
@@ -1004,13 +1580,13 @@ function guessLanguage(text: string): string | undefined {
1004
1580
  }
1005
1581
 
1006
1582
  /** The user message each MAGI receives. The language reminder sits after the question, where the model reads it last. */
1007
- function councilPrompt(project: string, excerpt: string, question: string): string {
1583
+ function councilPrompt(project: string, context: string, question: string, contextLabel = "Recent conversation (context only, may be empty)"): string {
1008
1584
  const lang = guessLanguage(question);
1009
1585
  const reminder = lang
1010
1586
  ? `(Write your whole answer in ${lang}, even if technical terms in the question are English.)`
1011
1587
  : "(Write your whole answer in the language of this question, even if technical terms in it are English.)";
1012
1588
  return (
1013
- `Project: ${project}\n\nRecent conversation (context only, may be empty):\n<conversation>\n${excerpt}\n</conversation>\n\n` +
1589
+ `Project: ${project}\n\n${contextLabel}:\n<context>\n${context}\n</context>\n\n` +
1014
1590
  `Question for the MAGI:\n${question}\n\n${reminder}`
1015
1591
  );
1016
1592
  }
@@ -1089,19 +1665,17 @@ function buildDeliberationView(
1089
1665
  out.push(row(" " + theme.fg("muted", question)));
1090
1666
  out.push(row(""));
1091
1667
 
1092
- // Pending units flicker until their model answers.
1093
- const spin = ["◐", "◓", "◑", "◒"][tick % 4]!;
1668
+ // Pending units flicker at random until their model answers.
1094
1669
  const units: UnitView[] = MAGI_UNITS.map((name, i) => {
1095
1670
  const o = opinions[i];
1096
- if (!o) return { name, status: `${spin} ···`, tone: "accent", lit: tick % 2 === 0 };
1671
+ if (!o) return { name, status: "···", tone: "accent", lit: flicker(tick, i) };
1097
1672
  if (!o.vote) return { name, status: "ERROR", tone: "error", lit: true };
1098
1673
  return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
1099
1674
  });
1100
1675
  const { verdict, tally } = tallyVerdict(opinions.map((o) => o?.vote ?? null));
1101
- const link: ThemeColor = done ? voteTone(verdict) : "accent";
1102
1676
  const hub = done ? "◆MAGI◆" : ["─MAGI─", "━MAGI━"][tick % 2]!;
1103
1677
  const left = " ".repeat(Math.max(0, Math.floor((inner - MAGI_DIAGRAM_WIDTH) / 2)));
1104
- 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));
1105
1679
 
1106
1680
  out.push(row(""));
1107
1681
  for (let i = 0; i < MAGI.length; i++) {
@@ -1131,6 +1705,96 @@ function buildDeliberationView(
1131
1705
  };
1132
1706
  }
1133
1707
 
1708
+ /** A read-only boxed report (used by /magi-ui status); any key closes it. */
1709
+ function buildReportView(theme: Theme, lines: string[], close: () => void) {
1710
+ return {
1711
+ render(width: number): string[] {
1712
+ const inner = Math.max(40, Math.min(width - 2, 96));
1713
+ const row = (s: string) => {
1714
+ const t = truncateToWidth(" " + s, inner);
1715
+ return theme.fg("accent", "│") + t + " ".repeat(Math.max(0, inner - visibleWidth(t))) + theme.fg("accent", "│");
1716
+ };
1717
+ return [
1718
+ theme.fg("accent", "┌" + "─".repeat(inner) + "┐"),
1719
+ ...lines.map(row),
1720
+ theme.fg("accent", "└" + "─".repeat(inner) + "┘"),
1721
+ theme.fg("dim", " any key: close"),
1722
+ ].map((l) => truncateToWidth(l, width));
1723
+ },
1724
+ handleInput(_data: string) {
1725
+ close();
1726
+ },
1727
+ invalidate() {},
1728
+ };
1729
+ }
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
+
1134
1798
  async function configureMagi(ctx: ExtensionContext): Promise<void> {
1135
1799
  const cfg = loadMagiConfig();
1136
1800
  const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
@@ -1145,7 +1809,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1145
1809
  }
1146
1810
  cfg[magi.unit] = { ...cfg[magi.unit], model: pick === SESSION ? undefined : pick };
1147
1811
  }
1148
- writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
1812
+ saveMagiConfig(cfg);
1149
1813
  ctx.ui.notify(`MAGI config saved to ${MAGI_CONFIG_PATH}`, "info");
1150
1814
  }
1151
1815
 
@@ -1154,11 +1818,12 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1154
1818
  /**
1155
1819
  * The footer's left side, one animation per real state of the agent:
1156
1820
  * - seals breaking / seventh seal opened → context compaction
1157
- * - golem EMET / MET → tool running / tool failed
1821
+ * - golem EMET / MET → tool running (with its file or command) / tool failed
1158
1822
  * - light pulsing in the upper triad → thinking
1159
1823
  * - light descending to Malkuth → streaming the answer
1160
1824
  * - light ascending from Malkuth → loading the model into VRAM
1161
- * - at rest in Malkuth → idle
1825
+ * - the sixth seal → context close to compaction
1826
+ * - asleep / at rest in Malkuth → model unloaded / idle
1162
1827
  */
1163
1828
  function footerLeft(th: Theme, now = Date.now()): string {
1164
1829
  const dim = (s: string) => th.fg("dim", s);
@@ -1190,14 +1855,17 @@ function footerLeft(th: Theme, now = Date.now()): string {
1190
1855
  const tally = th.fg("success", ` CHESED ✓${state.toolOk}`) + th.fg(state.toolFail ? "error" : "dim", ` GEBURAH ✗${state.toolFail}`);
1191
1856
  const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
1192
1857
  if (now - state.lastFailAt < FAIL_FLASH_MS) {
1193
- return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + dim(" ·") + tally + syncText;
1858
+ 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;
1194
1860
  }
1195
1861
  const hammer = ["▚", "▞"][step % 2]!;
1862
+ const target = state.toolTarget ? dim(" ") + th.fg("muted", truncateToWidth(state.toolTarget, 40)) : "";
1196
1863
  return (
1197
1864
  th.fg("accent", `${hammer} GOLEM · `) +
1198
1865
  th.bold(th.fg("warning", "EMET")) +
1199
1866
  dim(" · ") +
1200
1867
  th.fg("text", state.toolName || "tool") +
1868
+ target +
1201
1869
  dim(` ${secsSince(state.phaseSince, now)}s ·`) +
1202
1870
  tally +
1203
1871
  syncText
@@ -1233,6 +1901,19 @@ function footerLeft(th: Theme, now = Date.now()): string {
1233
1901
  dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
1234
1902
  );
1235
1903
  }
1904
+ const percent = contextUsage()?.percent ?? 0;
1905
+ if (percent >= SIXTH_SEAL_PERCENT) {
1906
+ const cmd = state.hasSmartCompact ? "/smart-compact" : "/compact";
1907
+ return (
1908
+ th.fg(step % 4 < 2 ? "error" : "warning", "⚠ SIXTH SEAL") +
1909
+ dim(` · context ${percent.toFixed(0)}% · compact now with `) +
1910
+ th.fg("text", cmd) +
1911
+ dim(" before it happens mid-task")
1912
+ );
1913
+ }
1914
+ if (swap.state === "asleep") {
1915
+ return th.fg("muted", "○ MALKUTH") + dim(" · model asleep in llama-swap · type to wake it");
1916
+ }
1236
1917
  const last = state.lastRunMs ? ` · last run ${fmtMs(state.lastRunMs)}` : "";
1237
1918
  return th.fg("success", "○ ") + th.fg("muted", "MALKUTH") + dim(` · the kingdom · at rest${last}`);
1238
1919
  }
@@ -1265,14 +1946,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1265
1946
 
1266
1947
  /* ───────────────────────────────────────────────────────── extension ── */
1267
1948
 
1949
+ const BUSY_POLL_MS = 3_000;
1950
+ const IDLE_POLL_MS = 30_000;
1951
+ /** pi rewrites the window title on its own (right after startup, on session info changes), so ours is re-applied. */
1952
+ const TITLE_REFRESH_MS = 2_000;
1953
+
1954
+ /** Window title: "π - Magi - <working directory>", prefixed with ✓ after a long run until you are back. */
1955
+ function windowTitle(cwd: string, done = false): string {
1956
+ const home = homedir();
1957
+ const dir = cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
1958
+ return `${done ? "✓ " : ""}π - Magi - ${dir}`;
1959
+ }
1960
+
1268
1961
  export default function (pi: ExtensionAPI) {
1269
1962
  let chrome = true;
1270
1963
  let panelEnabled = true;
1271
1964
  let tuiRef: TUI | undefined;
1272
1965
  let panelHandle: OverlayHandle | undefined;
1273
1966
  let panel: MagiPanel | undefined;
1967
+ let titleDone = false;
1274
1968
 
1275
- const repaint = () => tuiRef?.requestRender();
1969
+ const repaint = () => {
1970
+ panel?.invalidate();
1971
+ tuiRef?.requestRender();
1972
+ };
1276
1973
 
1277
1974
  let footerDataRef: any;
1278
1975
  let wrappedRoot: any; // pi's original root, when the panel is a fullscreen column
@@ -1286,6 +1983,7 @@ export default function (pi: ExtensionAPI) {
1286
1983
  // ponytail: reads TuiAltScreen's private layoutRoot field; if pi renames it, falls back to the overlay.
1287
1984
  const t = tuiRef as any;
1288
1985
  if (t.mode === "fullscreen" && t.layoutRoot && typeof t.setLayoutRoot === "function") {
1986
+ panel.fillHeight = () => process.stdout.rows ?? 0;
1289
1987
  wrappedRoot = t.layoutRoot;
1290
1988
  t.setLayoutRoot(
1291
1989
  new HStack(
@@ -1347,7 +2045,7 @@ export default function (pi: ExtensionAPI) {
1347
2045
  return buildFooter(tui, theme, footerData);
1348
2046
  });
1349
2047
  ctx.ui.setWorkingIndicator({ frames: pulseFrames(ctx.ui.theme), intervalMs: 110 });
1350
- ctx.ui.setTitle("MAGI");
2048
+ ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
1351
2049
  };
1352
2050
 
1353
2051
  pi.registerEntryRenderer("magi-verdict", (entry: any, _options: any, theme: Theme) => {
@@ -1371,15 +2069,101 @@ export default function (pi: ExtensionAPI) {
1371
2069
  });
1372
2070
 
1373
2071
  let metricsTimer: ReturnType<typeof setInterval> | undefined;
2072
+ let unsubscribeInput: (() => void) | undefined;
2073
+ let lastPoll = 0;
2074
+ let titleTimer: ReturnType<typeof setInterval> | undefined;
2075
+ 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. */
2077
+ const runningTools = new Map<string, { name: string; target: string }>();
2078
+
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
+ };
1374
2114
 
1375
- pi.on("session_start", async (_event, ctx) => {
2115
+ pi.on("session_start", async (event, ctx) => {
1376
2116
  liveCtx = ctx;
2117
+ recountSession(ctx);
2118
+ state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
2119
+ const cfg = loadMagiConfig();
2120
+ ui.compact = cfg.ui?.compact ?? false;
2121
+ ui.kwhPrice = cfg.ui?.kwhPrice;
2122
+ ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
1377
2123
  if (ctx.mode !== "tui") return;
1378
2124
  applyChrome(ctx);
1379
- // load the model into VRAM now (animated in the panel and footer) and keep GPU stats fresh
1380
- void preloadModel(ctx);
1381
- metricsTimer ??= setInterval(() => void refreshSwapMetrics(), 3000);
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));
2128
+ // GPU stats every 3s while something happens, every 30s when idle
2129
+ metricsTimer ??= setInterval(() => {
2130
+ const now = Date.now();
2131
+ if (!animating(now) && now - lastPoll < IDLE_POLL_MS) return;
2132
+ lastPoll = now;
2133
+ void refreshSwapMetrics()
2134
+ .then(refreshSwapRunning)
2135
+ .then(repaint);
2136
+ }, BUSY_POLL_MS);
1382
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?.();
2143
+ // back at the keyboard: clear the ✓ from the title, wake a model llama-swap unloaded
2144
+ unsubscribeInput ??= ctx.ui.onTerminalInput(() => {
2145
+ if (titleDone) {
2146
+ titleDone = false;
2147
+ ctx.ui.setTitle(windowTitle(ctx.cwd));
2148
+ }
2149
+ if (swap.state === "asleep" && liveCtx && !pickerOpen) void preloadModel(liveCtx);
2150
+ return undefined;
2151
+ });
2152
+ // ponytail: re-applies the title every 2s because pi overwrites it without telling extensions
2153
+ titleTimer ??= setInterval(() => {
2154
+ if (chrome) ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
2155
+ }, TITLE_REFRESH_MS);
2156
+ titleTimer.unref?.();
2157
+ });
2158
+
2159
+ pi.on("session_tree", async (_event, ctx) => {
2160
+ liveCtx = ctx;
2161
+ recountSession(ctx);
2162
+ repaint();
2163
+ });
2164
+
2165
+ pi.on("before_provider_request", (event, ctx) => {
2166
+ rememberPrefix(ctx.cwd, event.payload);
1383
2167
  });
1384
2168
 
1385
2169
  pi.on("model_select", async (event, ctx) => {
@@ -1388,8 +2172,15 @@ export default function (pi: ExtensionAPI) {
1388
2172
  });
1389
2173
 
1390
2174
  pi.on("session_shutdown", async () => {
2175
+ prewarm.abort?.abort();
1391
2176
  clearInterval(metricsTimer);
1392
2177
  metricsTimer = undefined;
2178
+ unsubscribeInput?.();
2179
+ unsubscribeInput = undefined;
2180
+ clearInterval(titleTimer);
2181
+ titleTimer = undefined;
2182
+ clearInterval(liveTimer);
2183
+ liveTimer = undefined;
1393
2184
  hidePanel();
1394
2185
  });
1395
2186
 
@@ -1399,6 +2190,7 @@ export default function (pi: ExtensionAPI) {
1399
2190
 
1400
2191
  pi.on("turn_start", async (_event, ctx) => {
1401
2192
  liveCtx = ctx;
2193
+ prewarm.abort?.abort();
1402
2194
  state.turns++;
1403
2195
  setPhase("thinking");
1404
2196
  if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage("the MAGI deliberate…");
@@ -1419,7 +2211,15 @@ export default function (pi: ExtensionAPI) {
1419
2211
  const t = e?.type;
1420
2212
  if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
1421
2213
  else if (t === "text_start" || t === "text_delta") setPhase("responding");
1422
- else if (t === "toolcall_start" || t === "toolcall_delta") setPhase("tool");
2214
+ 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
2216
+ setPhase("tool");
2217
+ const call = e.partial?.content?.[e.contentIndex];
2218
+ if (call?.type === "toolCall") {
2219
+ state.toolName = call.name ?? "";
2220
+ state.toolTarget = toolTarget(call.arguments);
2221
+ }
2222
+ }
1423
2223
  if (typeof e?.delta === "string" && e.delta) {
1424
2224
  if (!perf.first) perf.first = Date.now();
1425
2225
  perf.chars += e.delta.length;
@@ -1427,20 +2227,23 @@ export default function (pi: ExtensionAPI) {
1427
2227
  });
1428
2228
 
1429
2229
  pi.on("message_end", async (event) => {
1430
- if (event.message.role !== "assistant" || !perf.start) return;
2230
+ if (event.message.role !== "assistant") return;
1431
2231
  const m = event.message as AssistantMessage;
1432
- const end = Date.now();
1433
- perf.lastMs = end - perf.start;
1434
- if (perf.first) {
1435
- perf.ttft = perf.first - perf.start;
1436
- const genS = (end - perf.first) / 1000;
1437
- const out = m.usage?.output || perf.chars / 4;
1438
- if (genS > 0.05 && out) {
1439
- perf.tps = out / genS;
1440
- perf.peakTps = Math.max(perf.peakTps, perf.tps);
2232
+ addUsage(m);
2233
+ if (perf.start) {
2234
+ const end = Date.now();
2235
+ perf.lastMs = end - perf.start;
2236
+ if (perf.first) {
2237
+ perf.ttft = perf.first - perf.start;
2238
+ const genS = (end - perf.first) / 1000;
2239
+ const out = m.usage?.output || perf.chars / 4;
2240
+ if (genS > 0.05 && out) {
2241
+ perf.tps = out / genS;
2242
+ perf.peakTps = Math.max(perf.peakTps, perf.tps);
2243
+ }
1441
2244
  }
2245
+ perf.start = 0;
1442
2246
  }
1443
- perf.start = 0;
1444
2247
  repaint();
1445
2248
  // llama-swap records the request once it completes
1446
2249
  setTimeout(() => void refreshSwapActivity().then(repaint), 300);
@@ -1448,20 +2251,37 @@ export default function (pi: ExtensionAPI) {
1448
2251
 
1449
2252
  pi.on("tool_execution_start", async (event, ctx) => {
1450
2253
  liveCtx = ctx;
2254
+ const tool = { name: event.toolName ?? "", target: toolTarget(event.args) };
2255
+ runningTools.set(event.toolCallId, tool);
1451
2256
  setPhase("tool");
1452
- state.toolName = event.toolName ?? "";
2257
+ state.toolName = tool.name;
2258
+ state.toolTarget = tool.target;
1453
2259
  repaint();
1454
2260
  });
1455
2261
 
1456
2262
  // CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
1457
2263
  pi.on("tool_execution_end", async (event) => {
2264
+ const tool = runningTools.get(event.toolCallId);
2265
+ runningTools.delete(event.toolCallId);
1458
2266
  if (event.isError) {
1459
2267
  state.toolFail++;
1460
2268
  state.lastFailAt = Date.now();
1461
- state.lastFailTool = event.toolName ?? "tool";
2269
+ state.lastFailTool = event.toolName ?? tool?.name ?? "tool";
2270
+ state.lastFailTarget = tool?.target ?? "";
1462
2271
  } else {
1463
2272
  state.toolOk++;
1464
2273
  }
2274
+ const next = [...runningTools.values()][0];
2275
+ if (next) {
2276
+ // parallel tools: the golem moves on to one still running
2277
+ state.toolName = next.name;
2278
+ state.toolTarget = next.target;
2279
+ } else {
2280
+ // all tools done: the model now reads their results and thinks about the next step
2281
+ setPhase("thinking");
2282
+ state.toolName = "";
2283
+ state.toolTarget = "";
2284
+ }
1465
2285
  repaint();
1466
2286
  });
1467
2287
 
@@ -1470,19 +2290,26 @@ export default function (pi: ExtensionAPI) {
1470
2290
  if (state.runStart) state.lastRunMs = Date.now() - state.runStart;
1471
2291
  state.runStart = 0;
1472
2292
  setPhase("idle");
2293
+ runningTools.clear();
1473
2294
  state.toolName = "";
1474
- if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage();
2295
+ state.toolTarget = "";
2296
+ if (ctx.mode === "tui" && chrome) {
2297
+ ctx.ui.setWorkingMessage();
2298
+ // a long run just finished: mark the window title until you touch the keyboard
2299
+ if (state.lastRunMs > DONE_TITLE_AFTER_MS) {
2300
+ titleDone = true;
2301
+ ctx.ui.setTitle(windowTitle(ctx.cwd, true));
2302
+ }
2303
+ }
1475
2304
  repaint();
1476
2305
  });
1477
2306
 
1478
2307
  // The seven seals: compaction breaks them, and the context is reborn.
1479
2308
  // When pi-smart-compact is installed it owns the compaction; this theme only watches and names it.
1480
- const hasSmartCompact = () => pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
1481
-
1482
2309
  pi.on("session_before_compact", async () => {
1483
2310
  state.compacting = true;
1484
2311
  state.compactSince = Date.now();
1485
- state.compactBy = hasSmartCompact() ? "smart-compact" : "pi native";
2312
+ state.compactBy = state.hasSmartCompact ? "smart-compact" : "pi native";
1486
2313
  repaint();
1487
2314
  });
1488
2315
 
@@ -1490,7 +2317,7 @@ export default function (pi: ExtensionAPI) {
1490
2317
  state.compacting = false;
1491
2318
  state.rebornAt = Date.now();
1492
2319
  // fromExtension: an extension supplied the summary; otherwise pi's own compactor did (e.g. smart-compact fell back)
1493
- state.compactBy = event.fromExtension ? (hasSmartCompact() ? "smart-compact" : "extension") : "pi native";
2320
+ state.compactBy = event.fromExtension ? (state.hasSmartCompact ? "smart-compact" : "extension") : "pi native";
1494
2321
  repaint();
1495
2322
  });
1496
2323
 
@@ -1499,52 +2326,87 @@ export default function (pi: ExtensionAPI) {
1499
2326
  repaint();
1500
2327
  });
1501
2328
 
2329
+ /** Runs the council on a question with some context, shows the deliberation, stores the verdict. */
2330
+ async function runCouncil(ctx: ExtensionContext, question: string, context: string, contextLabel?: string): Promise<void> {
2331
+ const cfg = loadMagiConfig();
2332
+ const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
2333
+ const prompt = councilPrompt(project, context, question, contextLabel);
2334
+
2335
+ const controller = new AbortController();
2336
+ const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
2337
+ let finished = false;
2338
+ const all = Promise.all(
2339
+ MAGI.map((_, i) =>
2340
+ askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
2341
+ opinions[i] = o;
2342
+ return o;
2343
+ }),
2344
+ ),
2345
+ ).finally(() => {
2346
+ finished = true;
2347
+ });
2348
+
2349
+ if (ctx.mode === "tui") {
2350
+ const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
2351
+ buildDeliberationView(tui, theme, question, opinions, () => finished, done),
2352
+ );
2353
+ if (cancelled) {
2354
+ controller.abort();
2355
+ ctx.ui.notify("MAGI deliberation aborted", "warning");
2356
+ return;
2357
+ }
2358
+ }
2359
+
2360
+ const final = await all;
2361
+ const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
2362
+ pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
2363
+ state.lastCouncil = { verdict, tally, question };
2364
+ repaint();
2365
+ }
2366
+
1502
2367
  pi.registerCommand("magi", {
1503
- description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi config assigns a model to each",
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",
1504
2369
  handler: async (args, ctx) => {
1505
2370
  const arg = args.trim();
1506
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
+ }
1507
2377
 
1508
- const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
1509
- if (!question) return;
1510
-
1511
- const cfg = loadMagiConfig();
1512
- const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
1513
- const prompt = councilPrompt(project, conversationExcerpt(ctx), question);
1514
-
1515
- const controller = new AbortController();
1516
- const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
1517
- let finished = false;
1518
- const all = Promise.all(
1519
- MAGI.map((_, i) =>
1520
- askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
1521
- opinions[i] = o;
1522
- return o;
1523
- }),
1524
- ),
1525
- ).finally(() => {
1526
- finished = true;
1527
- });
1528
-
1529
- if (ctx.mode === "tui") {
1530
- const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
1531
- buildDeliberationView(tui, theme, question, opinions, () => finished, done),
1532
- );
1533
- if (cancelled) {
1534
- controller.abort();
1535
- ctx.ui.notify("MAGI deliberation aborted", "warning");
2378
+ if (arg === "review" || arg.startsWith("review ")) {
2379
+ const focus = arg.slice("review".length).trim();
2380
+ let changes: { diff: string; untracked: string[] };
2381
+ try {
2382
+ changes = await pendingChanges(ctx.cwd);
2383
+ } catch {
2384
+ ctx.ui.notify("MAGI review needs a git repository", "error");
1536
2385
  return;
1537
2386
  }
2387
+ if (!changes.diff.trim() && !changes.untracked.length) {
2388
+ ctx.ui.notify("Nothing to review: no changes against HEAD", "info");
2389
+ return;
2390
+ }
2391
+ const diff =
2392
+ changes.diff.length > REVIEW_MAX_CHARS
2393
+ ? changes.diff.slice(0, REVIEW_MAX_CHARS) + `\n… diff truncated (${changes.diff.length} chars in total)`
2394
+ : changes.diff;
2395
+ const untracked = changes.untracked.length ? `\n\nUntracked files (content not shown):\n${changes.untracked.join("\n")}` : "";
2396
+ const question = focus
2397
+ ? `Review these pending changes before committing, focusing on: ${focus}`
2398
+ : "Review these pending changes before committing: are they ready to commit, and what must change first?";
2399
+ return runCouncil(ctx, question, "```diff\n" + diff + "\n```" + untracked, "Pending changes (git diff HEAD)");
1538
2400
  }
1539
2401
 
1540
- const final = await all;
1541
- const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
1542
- pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
2402
+ const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
2403
+ if (!question) return;
2404
+ return runCouncil(ctx, question, conversationExcerpt(ctx));
1543
2405
  },
1544
2406
  });
1545
2407
 
1546
2408
  pi.registerCommand("magi-ui", {
1547
- description: "MAGI chrome: enable the theme, or manage chrome and side panel (on|off|panel)",
2409
+ description: "MAGI chrome: enable the theme, or manage it (on|off|panel|compact|status|config)",
1548
2410
  handler: async (args, ctx) => {
1549
2411
  liveCtx = ctx;
1550
2412
  const arg = args.trim().toLowerCase();
@@ -1556,6 +2418,59 @@ export default function (pi: ExtensionAPI) {
1556
2418
  ctx.ui.notify(`Side panel ${panelEnabled ? "enabled" : "disabled"}`, "info");
1557
2419
  return;
1558
2420
  }
2421
+ if (arg === "compact") {
2422
+ ui.compact = !ui.compact;
2423
+ const cfg = loadMagiConfig();
2424
+ saveMagiConfig({ ...cfg, ui: { ...cfg.ui, compact: ui.compact } });
2425
+ repaint();
2426
+ ctx.ui.notify(`Side panel ${ui.compact ? "compact" : "detailed"}`, "info");
2427
+ return;
2428
+ }
2429
+ if (arg === "config") {
2430
+ const currency = await ctx.ui.select(`Currency for COST (current: ${ui.currency})`, ["EUR", "USD"]);
2431
+ if (!currency) return;
2432
+ const current = ui.kwhPrice !== undefined ? ` (current: ${ui.kwhPrice})` : "";
2433
+ const raw = (await ctx.ui.input(`Electricity price per kWh in ${currency}${current}:`, "0.30"))?.trim();
2434
+ if (raw === undefined) return;
2435
+ const price = raw === "" && ui.kwhPrice !== undefined ? ui.kwhPrice : Number(raw.replace(",", "."));
2436
+ if (raw === "" && ui.kwhPrice === undefined) {
2437
+ ctx.ui.notify("No price entered: COST unchanged", "warning");
2438
+ return;
2439
+ }
2440
+ if (!Number.isFinite(price) || price < 0) {
2441
+ ctx.ui.notify(`Invalid price: "${raw}"`, "error");
2442
+ return;
2443
+ }
2444
+ ui.currency = currency === "USD" ? "USD" : "EUR";
2445
+ ui.kwhPrice = price;
2446
+ const cfg = loadMagiConfig();
2447
+ saveMagiConfig({ ...cfg, ui: { ...cfg.ui, currency: ui.currency, kwhPrice: price } });
2448
+ repaint();
2449
+ ctx.ui.notify(`COST: ${fmtMoney(price)} per kWh`, "info");
2450
+ return;
2451
+ }
2452
+ if (arg === "status") {
2453
+ if (!swap.base) {
2454
+ ctx.ui.notify("/magi-ui status needs a llama-swap session model", "warning");
2455
+ return;
2456
+ }
2457
+ let report: { data?: ActivityRow[]; total?: number };
2458
+ try {
2459
+ report = (await (await swapGet(`/api/metrics/activity?limit=${ACTIVITY_REPORT_ROWS}`, 15_000)).json()) as typeof report;
2460
+ } catch (err) {
2461
+ ctx.ui.notify(`llama-swap status failed: ${err instanceof Error ? err.message : String(err)}`, "error");
2462
+ return;
2463
+ }
2464
+ const rows = report.data ?? [];
2465
+ if (!rows.length) {
2466
+ ctx.ui.notify("llama-swap has no recorded requests yet", "info");
2467
+ return;
2468
+ }
2469
+ await ctx.ui.custom<void>((_tui, theme, _keys, done) =>
2470
+ buildReportView(theme, activityReport(theme, rows, report.total ?? rows.length), () => done(undefined)),
2471
+ );
2472
+ return;
2473
+ }
1559
2474
  if (arg === "off" || arg === "on") {
1560
2475
  chrome = arg === "on";
1561
2476
  applyChrome(ctx);
@@ -1570,7 +2485,7 @@ export default function (pi: ExtensionAPI) {
1570
2485
  }
1571
2486
  chrome = true;
1572
2487
  applyChrome(ctx);
1573
- ctx.ui.notify("MAGI online — /magi-ui panel, /magi-ui off", "info");
2488
+ ctx.ui.notify("MAGI online — /magi-ui panel|compact|status|off", "info");
1574
2489
  },
1575
2490
  });
1576
2491
  }