pi-magi-theme 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,22 +4,24 @@
4
4
  * One public-domain mythology, every symbol bound to a real function:
5
5
  * - the TREE OF LIFE shows where the agent is: the upper triad while it thinks, the light descending
6
6
  * 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
7
+ * - the three MAGI (MELCHIOR / BALTHASAR / CASPAR) flicker while the agent works, showing what it is doing,
8
+ * and form the /magi council: three real models voting (on a question, or on the pending git diff)
9
+ * - the GOLEM acts: it is animated by EMET ("truth") while tools run, showing the file or command it works on;
10
+ * a failing tool erases the aleph and EMET becomes MET ("death"). SYNC is the tool success rate
11
11
  * - 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)
12
+ * - the SEVEN SEALS measure the context window; the sixth warns before compaction, the seventh opens when it runs
13
+ * - llama-swap telemetry: VRAM, GPU load/temp/power, energy used, RAM, server-side tok/s, prompt tok/s, cache hits
14
+ * - /magi config → assign a model to each MAGI; /magi-ui compact|status → smaller panel, llama-swap report
15
15
  *
16
16
  * Artwork is original; the symbolism is public domain.
17
17
  * Use with the theme ../../themes/magi.json
18
18
  */
19
19
 
20
+ import { execFile } from "node:child_process";
20
21
  import { readFileSync, writeFileSync } from "node:fs";
21
22
  import { homedir } from "node:os";
22
23
  import { join } from "node:path";
24
+ import { promisify } from "node:util";
23
25
  import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
24
26
  import { completeSimple } from "@earendil-works/pi-ai";
25
27
  import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
@@ -92,20 +94,20 @@ function renderTreeOfLife(th: Theme): string[] {
92
94
  }
93
95
  // ring of radius r, cleared inside and with a small gap around it so paths stop short of the circle;
94
96
  // dashed rings keep every other 30° arc
95
- const ring = (cx: number, cy: number, r: number, v: number, dashed: boolean) => {
97
+ const ring = (c: { x: number; y: number }, r: number, v: number, dashed: boolean) => {
96
98
  for (let y = -r - 2; y <= r + 2; y++) {
97
99
  for (let x = -r - 2; x <= r + 2; x++) {
98
100
  const d = Math.hypot(x, y);
99
101
  if (d > r + 1.5) continue;
100
102
  const onRing = d > r - 0.7 && d <= r + 0.5;
101
103
  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);
104
+ set(c.x + x, c.y + y, onRing && (!dashed || arc) ? v : 0);
103
105
  }
104
106
  }
105
107
  };
106
- ring(DAAT.x, DAAT.y, 3, DAAT_PX, true);
108
+ ring(DAAT, 3, DAAT_PX, true);
107
109
  for (const s of SEPHIROT) {
108
- ring(s.x, s.y, 3.5, NODE, false);
110
+ ring(s, 3.5, NODE, false);
109
111
  set(s.x, s.y, NODE);
110
112
  }
111
113
 
@@ -178,7 +180,6 @@ const GOLEM_FALLEN = [
178
180
 
179
181
  const MAGI_UNITS = ["MELCHIOR", "BALTHASAR", "CASPAR"] as const;
180
182
  type MagiUnit = (typeof MAGI_UNITS)[number];
181
- const MAGI_TASKS = ["ANALYSIS", "SYNTHESIS", "VERIFY"] as const;
182
183
 
183
184
  function pulseFrames(theme: Theme): string[] {
184
185
  return [
@@ -191,27 +192,52 @@ function pulseFrames(theme: Theme): string[] {
191
192
  ];
192
193
  }
193
194
 
195
+ /** Pseudo-random on/off per unit, changing every `hold` frames: the MAGI flicker while they work. */
196
+ function flicker(frame: number, unit: number, hold = 2): boolean {
197
+ const x = Math.imul(Math.floor(frame / hold) + 1, 2654435761) ^ Math.imul(unit + 1, 40503);
198
+ return (x >>> 0) % 100 < 55;
199
+ }
200
+
194
201
  /* ─────────────────────────────────────────────────────── live state ── */
195
202
 
196
203
  type Phase = "idle" | "thinking" | "responding" | "tool";
204
+ type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
197
205
 
198
206
  const state = {
199
207
  phase: "idle" as Phase,
200
208
  phaseSince: Date.now(),
201
209
  toolName: "",
210
+ toolTarget: "", // file or command the current tool works on
202
211
  turns: 0,
203
212
  toolOk: 0, // CHESED
204
213
  toolFail: 0, // GEBURAH
205
214
  lastFailAt: 0,
206
215
  lastFailTool: "",
216
+ lastFailTarget: "",
207
217
  runStart: 0,
208
218
  lastRunMs: 0,
209
219
  compacting: false,
210
220
  compactSince: 0,
211
221
  compactBy: "", // who opens the seals: "smart-compact" (pi-smart-compact package) or "pi native"
212
222
  rebornAt: 0,
223
+ hasSmartCompact: false,
224
+ lastCouncil: undefined as { verdict: Vote | null; tally: number; question: string } | undefined,
213
225
  };
214
226
 
227
+ /** Panel preferences, persisted under "ui" in ~/.pi/agent/magi.json. */
228
+ type Currency = "EUR" | "USD";
229
+
230
+ const ui = {
231
+ compact: false,
232
+ kwhPrice: undefined as number | undefined, // price per kWh, for the COST row (/magi-ui config)
233
+ currency: "EUR" as Currency,
234
+ };
235
+
236
+ function fmtMoney(value: number): string {
237
+ const digits = value < 1 ? 3 : 2;
238
+ return ui.currency === "USD" ? `$${value.toFixed(digits)}` : `${value.toFixed(digits)} €`;
239
+ }
240
+
215
241
  function setPhase(p: Phase): void {
216
242
  if (state.phase === p) return;
217
243
  state.phase = p;
@@ -221,6 +247,8 @@ function setPhase(p: Phase): void {
221
247
  const ANIM_STEP_MS = 220;
222
248
  const FAIL_FLASH_MS = 2500;
223
249
  const REBIRTH_MS = 6000;
250
+ const DONE_TITLE_AFTER_MS = 30_000;
251
+ const SIXTH_SEAL_PERCENT = (6 / 7) * 100;
224
252
 
225
253
  /** Golem obedience: share of tool calls that succeeded (null before the first tool). */
226
254
  function syncPercent(): number | null {
@@ -240,6 +268,17 @@ function animating(now = Date.now()): boolean {
240
268
  );
241
269
  }
242
270
 
271
+ /** What a tool works on, from its arguments: a path, a command, a pattern, or the first short string. */
272
+ function toolTarget(args: unknown): string {
273
+ if (!args || typeof args !== "object") return "";
274
+ const a = args as Record<string, unknown>;
275
+ for (const key of ["path", "file_path", "filePath", "command", "cmd", "pattern", "query", "url"]) {
276
+ if (typeof a[key] === "string" && a[key]) return String(a[key]).replace(/\s+/g, " ").trim();
277
+ }
278
+ const first = Object.values(a).find((v) => typeof v === "string" && v.length > 0 && v.length < 200);
279
+ return typeof first === "string" ? first.replace(/\s+/g, " ").trim() : "";
280
+ }
281
+
243
282
  /** Performance of the current stream / last message. */
244
283
  const perf = {
245
284
  start: 0, // message_start
@@ -270,6 +309,8 @@ const sessionStart = Date.now();
270
309
 
271
310
  let liveCtx: ExtensionContext | undefined;
272
311
 
312
+ /* ── token totals: counted once per message instead of rescanning the whole session ── */
313
+
273
314
  interface TokenStats {
274
315
  input: number;
275
316
  output: number;
@@ -277,24 +318,26 @@ interface TokenStats {
277
318
  cost: number;
278
319
  }
279
320
 
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;
321
+ const tokens: TokenStats = { input: 0, output: 0, cacheRead: 0, cost: 0 };
322
+
323
+ function addUsage(m: AssistantMessage): void {
324
+ tokens.input += m.usage?.input ?? 0;
325
+ tokens.output += m.usage?.output ?? 0;
326
+ tokens.cacheRead += m.usage?.cacheRead ?? 0;
327
+ tokens.cost += m.usage?.cost?.total ?? 0;
328
+ }
329
+
330
+ /** Full recount: on session start and when switching branch. Also restores the last council verdict. */
331
+ function recountSession(ctx: ExtensionContext): void {
332
+ Object.assign(tokens, { input: 0, output: 0, cacheRead: 0, cost: 0 });
333
+ state.lastCouncil = undefined;
334
+ for (const entry of ctx.sessionManager.getBranch()) {
335
+ if (entry.type === "message" && entry.message.role === "assistant") addUsage(entry.message as AssistantMessage);
336
+ if (entry.type === "custom" && entry.customType === "magi-verdict") {
337
+ const d = entry.data as { verdict?: Vote | null; tally?: number; question?: string } | undefined;
338
+ if (d && Array.isArray((d as any).opinions)) state.lastCouncil = { verdict: d.verdict ?? null, tally: d.tally ?? 0, question: d.question ?? "" };
294
339
  }
295
340
  }
296
- statsCache = { at: now, value };
297
- return value;
298
341
  }
299
342
 
300
343
  function fmtTokens(n: number): string {
@@ -322,11 +365,17 @@ function bar(theme: Theme, filled: number, total: number, tone: ThemeColor): str
322
365
  return theme.fg(tone, "▓".repeat(f)) + theme.fg("dim", "░".repeat(total - f));
323
366
  }
324
367
 
325
- /** The seven seals of the context window: one breaks every 1/7 of it. */
326
- function renderSeals(theme: Theme, percent: number): string {
368
+ /** The seven seals of the context window: one breaks every 1/7 of it; the sixth blinks as a warning. */
369
+ function renderSeals(theme: Theme, percent: number, now = Date.now()): string {
327
370
  const broken = Math.min(7, Math.floor((percent / 100) * 7));
328
371
  const tone = usageTone(percent);
329
- return theme.fg(tone, "◉".repeat(broken)) + theme.fg("dim", "○".repeat(7 - broken));
372
+ let out = "";
373
+ for (let i = 0; i < 7; i++) {
374
+ if (i >= broken) out += theme.fg("dim", "○");
375
+ else if (broken === 6 && i === 5) out += theme.fg(Math.floor(now / 500) % 2 ? "warning" : "error", "◉");
376
+ else out += theme.fg(tone, "◉");
377
+ }
378
+ return out;
330
379
  }
331
380
 
332
381
  /* ──────────────────────────────────────────────────── MAGI triangle ── */
@@ -396,14 +445,15 @@ function buildHeader(theme: Theme) {
396
445
  return lines.map((l) => truncateToWidth(l, width));
397
446
  }
398
447
 
399
- // Wide layout: tree left, wordmark right.
448
+ // Wide layout: tree left, wordmark right (rows chosen to center the text block on the tree).
449
+ const [wordAt, triadAt] = [5, 12];
400
450
  const lines = [""];
401
451
  for (let i = 0; i < tree.length; i++) {
402
452
  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);
453
+ if (i >= wordAt && i < wordAt + MAGI_WORD.length) right = orange(MAGI_WORD[i - wordAt]!);
454
+ else if (i === triadAt) right = dim("├─ ") + triad + dim(" ─┤");
455
+ else if (i === triadAt + 1) right = muted(subtitle);
456
+ else if (i === triadAt + 2) right = dim(lore);
407
457
  lines.push(truncateToWidth(tree[i]! + " " + right, width));
408
458
  }
409
459
  lines.push("");
@@ -424,13 +474,14 @@ interface GpuStat {
424
474
  power: number;
425
475
  }
426
476
 
427
- type SwapState = "off" | "checking" | "loading" | "ready" | "error";
477
+ type SwapState = "off" | "checking" | "loading" | "ready" | "asleep" | "error";
428
478
 
429
479
  /** Live state of the llama-swap server behind the session model (only when the provider is "llama-swap"). */
430
480
  const swap = {
431
481
  base: "", // e.g. http://host:9292
432
482
  headers: {} as Record<string, string>,
433
- modelId: "",
483
+ modelId: "", // the id pi uses (may be an alias)
484
+ realId: "", // the model llama-swap actually runs for that id
434
485
  state: "off" as SwapState,
435
486
  since: 0, // when the current state started
436
487
  loadMs: 0, // how long the last real load took
@@ -442,6 +493,9 @@ const swap = {
442
493
  srvPps: 0, // server-measured prompt processing tok/s
443
494
  cacheTokens: 0,
444
495
  inputTokens: 0,
496
+ energyWh: 0, // GPU energy since the session started
497
+ lastSampleAt: 0,
498
+ lastWatts: 0,
445
499
  };
446
500
 
447
501
  function setSwapState(s: SwapState, error = ""): void {
@@ -478,20 +532,46 @@ function parseSwapMetrics(text: string): void {
478
532
  swap.gpus = [...gpus.entries()].sort(([a], [b]) => Number(a) - Number(b)).map(([, g]) => g);
479
533
  }
480
534
 
535
+ /**
536
+ * Integrates GPU power over time into Wh.
537
+ * ponytail: trapezoid over the 3–30s poll samples, GPUs only; gaps over 60s are clamped.
538
+ */
539
+ function sampleEnergy(now = Date.now()): void {
540
+ const watts = swap.gpus.reduce((sum, g) => sum + g.power, 0);
541
+ if (swap.lastSampleAt) {
542
+ const dtH = Math.min(60_000, now - swap.lastSampleAt) / 3_600_000;
543
+ swap.energyWh += ((swap.lastWatts + watts) / 2) * dtH;
544
+ }
545
+ swap.lastSampleAt = now;
546
+ swap.lastWatts = watts;
547
+ }
548
+
481
549
  async function refreshSwapMetrics(): Promise<void> {
482
550
  if (!swap.base) return;
483
551
  try {
484
552
  parseSwapMetrics(await (await swapGet("/metrics")).text());
553
+ sampleEnergy();
485
554
  } catch {
486
555
  // server unreachable: keep the last values
487
556
  }
488
557
  }
489
558
 
490
- /** Server-side token metrics of the most recent request (/api/metrics/activity, newest first). */
559
+ /** Notices when llama-swap unloaded the session model (e.g. its ttl expired) so the panel can say so. */
560
+ async function refreshSwapRunning(): Promise<void> {
561
+ if (!swap.base || swap.state !== "ready") return;
562
+ try {
563
+ const { running } = (await (await swapGet("/running")).json()) as { running?: { model: string }[] };
564
+ if (!(running ?? []).some((r) => r.model === swap.realId)) setSwapState("asleep");
565
+ } catch {
566
+ // optional
567
+ }
568
+ }
569
+
570
+ /** Server-side token metrics of the most recent request: a single row from /api/metrics/activity. */
491
571
  async function refreshSwapActivity(): Promise<void> {
492
572
  if (!swap.base) return;
493
573
  try {
494
- const { data } = (await (await swapGet("/api/metrics/activity")).json()) as { data?: any[] };
574
+ const { data } = (await (await swapGet("/api/metrics/activity?limit=1")).json()) as { data?: any[] };
495
575
  const t = data?.[0]?.tokens;
496
576
  if (!t) return;
497
577
  if (t.tokens_per_second > 0) swap.srvTps = t.tokens_per_second;
@@ -515,6 +595,7 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
515
595
  }
516
596
  const id = model.id;
517
597
  swap.modelId = id;
598
+ swap.realId = id;
518
599
  setSwapState("checking");
519
600
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
520
601
  swap.base = model.baseUrl.replace(/\/v1\/?$/, "");
@@ -524,6 +605,14 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
524
605
  };
525
606
  void refreshSwapMetrics();
526
607
  void refreshSwapActivity();
608
+ // an alias (e.g. "… - Instruct") runs another model: /running lists that one
609
+ try {
610
+ const { data } = (await (await swapGet("/v1/models")).json()) as { data?: any[] };
611
+ const entry = data?.find((m) => m.id === id);
612
+ if (entry?.meta?.llamaswap?.type === "alias" && entry.meta.llamaswap.modelID) swap.realId = entry.meta.llamaswap.modelID;
613
+ } catch {
614
+ // keep the id itself
615
+ }
527
616
 
528
617
  // no answer within 400ms → the model isn't in VRAM and is being loaded
529
618
  const slow = setTimeout(() => {
@@ -544,18 +633,87 @@ async function preloadModel(ctx: ExtensionContext, model: Model<any> | undefined
544
633
  }
545
634
  }
546
635
 
636
+ /* ── /magi-ui status: a report built from the last requests llama-swap recorded ── */
637
+
638
+ interface ActivityRow {
639
+ timestamp: string;
640
+ model: string;
641
+ resp_status_code: number;
642
+ duration_ms: number;
643
+ tokens?: {
644
+ cache_tokens?: number;
645
+ input_tokens?: number;
646
+ output_tokens?: number;
647
+ prompt_per_second?: number;
648
+ tokens_per_second?: number;
649
+ draft_tokens?: number;
650
+ draft_acc_tokens?: number;
651
+ };
652
+ }
653
+
654
+ const ACTIVITY_REPORT_ROWS = 100;
655
+
656
+ function activityReport(th: Theme, rows: ActivityRow[], total: number, now = Date.now()): string[] {
657
+ const dim = (s: string) => th.fg("dim", s);
658
+ const mean = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
659
+ const ago = (iso: string) => {
660
+ const m = Math.max(0, Math.round((now - Date.parse(iso)) / 60000));
661
+ return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`;
662
+ };
663
+ const out = [th.fg("accent", th.bold("LLAMA-SWAP STATUS")) + dim(` · last ${rows.length} of ${total} requests`)];
664
+ const byModel = new Map<string, ActivityRow[]>();
665
+ for (const r of rows) byModel.set(r.model, [...(byModel.get(r.model) ?? []), r]);
666
+
667
+ for (const [model, rs] of byModel) {
668
+ const t = rs.map((r) => r.tokens ?? {});
669
+ const sum = (k: keyof NonNullable<ActivityRow["tokens"]>) => t.reduce((a, x) => a + Math.max(0, x[k] ?? 0), 0);
670
+ const errors = rs.filter((r) => r.resp_status_code >= 400).length;
671
+ const gen = mean(t.map((x) => x.tokens_per_second ?? 0).filter((v) => v > 0));
672
+ const prompt = mean(t.map((x) => x.prompt_per_second ?? 0).filter((v) => v > 0));
673
+ const cached = sum("cache_tokens");
674
+ const input = sum("input_tokens");
675
+ const draft = sum("draft_tokens");
676
+ const accepted = sum("draft_acc_tokens");
677
+ const durations = rs.map((r) => r.duration_ms).sort((a, b) => a - b);
678
+ const p95 = durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))] ?? 0;
679
+ out.push("", th.fg("text", th.bold(model)));
680
+ 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)}`));
681
+ out.push(
682
+ dim(" speed ") +
683
+ th.fg("success", `gen ${gen.toFixed(1)} tok/s`) +
684
+ dim(" · ") +
685
+ th.fg("text", `prompt ${prompt.toFixed(1)} tok/s`) +
686
+ (draft ? dim(" · draft accepted ") + th.fg("text", `${Math.round((accepted / draft) * 100)}%`) : ""),
687
+ );
688
+ out.push(
689
+ dim(" tokens ") +
690
+ th.fg("text", `in ${fmtTokens(input)} · cached ${fmtTokens(cached)}`) +
691
+ dim(` (hit ${cached + input ? Math.round((cached / (cached + input)) * 100) : 0}%)`) +
692
+ th.fg("text", ` · out ${fmtTokens(sum("output_tokens"))}`),
693
+ );
694
+ out.push(dim(" duration ") + th.fg("text", `avg ${fmtMs(mean(durations))} · p95 ${fmtMs(p95)} · max ${fmtMs(durations.at(-1) ?? 0)}`));
695
+ }
696
+ const slowest = [...rows].sort((a, b) => b.duration_ms - a.duration_ms)[0];
697
+ if (slowest) {
698
+ const st = slowest.tokens ?? {};
699
+ 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)}`));
700
+ }
701
+ return out;
702
+ }
703
+
547
704
  /* ─────────────────────────────────────────────────────── side panel ── */
548
705
 
549
706
  const PANEL_WIDTH = MAGI_DIAGRAM_WIDTH + 2;
550
707
 
551
- /** Frames per deliberation cycle while thinking: units light up one by one, then consensus. */
552
- const CYCLE = 32;
553
-
554
708
  class MagiPanel implements Component {
555
709
  private frame = 0;
556
710
  private timer: ReturnType<typeof setInterval> | null = null;
711
+ /** The data rows change slowly: they are rebuilt at most twice a second, only the council animates every frame. */
712
+ private cache: { at: number; inner: number; compact: boolean; lines: string[] } | undefined;
557
713
  /** git branch, provided by the footer (the only place pi exposes it). */
558
714
  branch?: () => string | null | undefined;
715
+ /** When set (fullscreen column), the panel pads itself down to this many rows. */
716
+ fillHeight?: () => number;
559
717
 
560
718
  constructor(
561
719
  private readonly tui: TUI,
@@ -568,7 +726,9 @@ class MagiPanel implements Component {
568
726
  }, 125);
569
727
  }
570
728
 
571
- invalidate(): void {}
729
+ invalidate(): void {
730
+ this.cache = undefined;
731
+ }
572
732
 
573
733
  dispose(): void {
574
734
  if (this.timer) clearInterval(this.timer);
@@ -588,12 +748,19 @@ class MagiPanel implements Component {
588
748
  return b("│") + this.pad(content, inner) + b("│");
589
749
  }
590
750
 
591
- private sep(inner: number, label?: string): string {
751
+ private labelLine(inner: number, label: string, left: string, right: string, tone: ThemeColor): string {
592
752
  const b = (s: string) => this.theme.fg("border", s);
593
- if (!label) return b("├" + "─".repeat(inner) + "┤");
594
753
  const text = ` ${truncateToWidth(label, inner - 4)} `;
595
754
  const rest = Math.max(0, inner - visibleWidth(text) - 1);
596
- return b("├─") + this.theme.fg("muted", text) + b("─".repeat(rest) + "┤");
755
+ return b(left + "─") + this.theme.fg(tone, text) + b("─".repeat(rest) + right);
756
+ }
757
+
758
+ private sep(inner: number, label: string): string {
759
+ return this.labelLine(inner, label, "├", "┤", "muted");
760
+ }
761
+
762
+ private centered(content: string, inner: number): string {
763
+ return this.frameLine(" ".repeat(Math.max(0, Math.floor((inner - visibleWidth(content)) / 2))) + content, inner);
597
764
  }
598
765
 
599
766
  private field(label: string, value: string, inner: number, tone: ThemeColor = "text"): string {
@@ -617,151 +784,113 @@ class MagiPanel implements Component {
617
784
  });
618
785
  }
619
786
 
620
- /* ── MAGI triangle (thinking, verdicts, boot) or the golem (tools) ── */
787
+ /* ── top of the panel: the MAGI (flickering while working) or the golem (tools) ── */
621
788
 
622
789
  private councilSection(inner: number): string[] {
623
790
  const th = this.theme;
624
791
  const f = this.frame;
625
792
  const now = Date.now();
793
+ const secs = (t: number) => `${secsSince(t, now)}s`;
794
+ const pulse = animating(now) ? pulseFrames(th)[f % 6]! : th.fg("dim", "◇");
795
+
796
+ const idle = state.phase === "idle";
797
+ const fallen = now - state.lastFailAt < FAIL_FLASH_MS;
626
798
  let title: string;
799
+ let titleTone: ThemeColor = "accent";
627
800
  let body: string[];
801
+ let status: string;
628
802
 
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`;
803
+ if (state.phase === "tool" || (idle && fallen)) {
804
+ title = fallen ? `GOLEM FELL · MET · ${state.lastFailTool}` : `GOLEM · ${state.toolName || "tool"} · ${secs(state.phaseSince)}`;
805
+ titleTone = fallen ? "error" : "warning";
639
806
  body = this.golemArt(inner);
807
+ const target = (fallen ? state.lastFailTarget || state.lastFailTool : state.toolTarget || state.toolName) || "tool";
808
+ status = `${pulse} ${th.fg(fallen ? "error" : "text", truncateToWidth(target, inner - 4))}`;
640
809
  } 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) {
810
+ let word: string;
811
+ let tone: ThemeColor;
812
+ let link: ThemeColor = "dim";
813
+ let hub = "─MAGI─";
814
+ let lit = (i: number) => flicker(f, i);
815
+ const breathing = ["─MAGI─", "━MAGI━"][f % 2]!;
816
+
817
+ if (state.compacting) {
818
+ title = `COMPACTING · ${state.compactBy || "context"} · ${secs(state.compactSince)}`;
819
+ [word, tone, link, hub, titleTone] = ["COMPACTING", "warning", "warning", ["─SEAL─", "━SEAL━"][f % 2]!, "warning"];
820
+ } else if (now - state.rebornAt < REBIRTH_MS) {
821
+ title = "SEVENTH SEAL OPENED";
822
+ [word, tone, link, hub, titleTone] = ["REBORN", "success", "success", "═MAGI═", "success"];
823
+ lit = () => true;
824
+ } else if (idle && (swap.state === "checking" || swap.state === "loading")) {
825
+ title = swap.state === "loading" ? `LOADING MODEL · ${secs(swap.since)}` : "CHECKING MODEL";
826
+ [word, tone, link, hub, titleTone] = ["LOADING", "warning", "warning", breathing, "warning"];
827
+ } else if (idle && swap.state === "error") {
671
828
  title = "MAGI OFFLINE";
672
- link = "error";
673
- hub = "─ ╳ ──";
674
- units = names.map((name) => ({ name, status: "OFFLINE", tone: "error", lit: f % 16 < 8 }));
829
+ [word, tone, link, hub, titleTone] = ["OFFLINE", "error", "error", "─ ╳ ──", "error"];
830
+ lit = () => f % 16 < 8;
831
+ } else if (idle && swap.state === "asleep") {
832
+ title = "MODEL ASLEEP · type to wake";
833
+ [word, tone, titleTone] = ["ASLEEP", "muted", "muted"];
834
+ lit = () => false;
675
835
  } else if (idle && swap.state === "ready" && now - swap.since < 3000) {
676
836
  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 }));
837
+ [word, tone, link, hub, titleTone] = ["ONLINE", "success", "success", "═MAGI═", "success"];
838
+ lit = () => true;
680
839
  } 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]!;
840
+ title = `THINKING · ${secs(state.phaseSince)}`;
841
+ [word, tone, link, hub] = ["THINKING", "accent", "accent", breathing];
693
842
  } 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 }));
843
+ const tps = liveTps();
844
+ title = `RESPONDING${tps ? ` · ${tps.toFixed(1)} tok/s` : ""}`;
845
+ [word, tone, link, hub, titleTone] = ["RESPONDING", "success", "success", breathing, "success"];
698
846
  } else {
699
- title = "MAGI · AWAITING QUESTION";
700
- link = "dim";
701
- hub = "─MAGI─";
702
- units = names.map((name) => ({ name, status: "STANDBY", tone: "success", lit: false }));
847
+ title = "STANDBY";
848
+ [word, tone, titleTone] = ["STANDBY", "muted", "muted"];
849
+ lit = () => false;
703
850
  }
851
+
852
+ const units = MAGI_UNITS.map((name, i) => ({ name, status: word, tone, lit: lit(i) }));
704
853
  body = magiDiagram(th, units[1]!, units[2]!, units[0]!, link, hub);
854
+ const c = state.lastCouncil;
855
+ status = c
856
+ ? `${pulse} ${th.fg("dim", "COUNCIL ")}${th.fg(voteTone(c.verdict), `${c.verdict ?? "NO QUORUM"} ${c.tally}/3`)}`
857
+ : `${pulse} ${th.fg("dim", "COUNCIL · /magi")}`;
705
858
  }
706
859
 
707
- const out = [this.sep(inner, title)];
708
- for (const l of body) out.push(this.frameLine(l, inner));
709
-
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;
860
+ return [this.labelLine(inner, title, "┌", "┐", titleTone), ...body.map((l) => this.frameLine(l, inner)), this.centered(status, inner)];
723
861
  }
724
862
 
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) + "┐"));
863
+ /* ── data rows ── */
734
864
 
735
- out.push(...this.councilSection(inner));
736
-
737
- // session data
738
- out.push(this.sep(inner, "SESSION"));
865
+ private sessionRows(inner: number, compact: boolean): string[] {
866
+ const out = [this.sep(inner, "SESSION")];
739
867
  const model = liveCtx?.model;
740
868
  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"));
869
+ if (!compact) out.push(this.field("PROVIDER", truncateToWidth(String(model?.provider ?? "—"), inner - 11), inner, "muted"));
742
870
  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"));
871
+ if (!compact) {
872
+ const cwd = (liveCtx?.cwd ?? "").split("/").filter(Boolean).pop() ?? "—";
873
+ out.push(this.field("PROJECT", truncateToWidth(cwd, inner - 11), inner, "muted"));
874
+ const branch = this.branch?.();
875
+ if (branch) out.push(this.field("BRANCH", truncateToWidth(branch, inner - 11), inner, "muted"));
876
+ }
877
+ return out;
878
+ }
879
+
880
+ private telemetryRows(inner: number, compact: boolean): string[] {
881
+ const th = this.theme;
882
+ const out = [this.sep(inner, "TELEMETRY")];
883
+ if (!compact) {
884
+ out.push(this.frameLine(` ${th.fg("dim", "TURNS".padEnd(9))}${th.fg("text", String(state.turns))}`, inner));
885
+ out.push(
886
+ this.frameLine(
887
+ ` ${th.fg("dim", "TOKENS".padEnd(9))}${th.fg("success", "↑" + fmtTokens(tokens.input))} ${th.fg("warning", "↓" + fmtTokens(tokens.output))}`,
888
+ inner,
889
+ ),
890
+ );
891
+ if (tokens.cacheRead) out.push(this.field("CACHE", fmtTokens(tokens.cacheRead), inner, "muted"));
892
+ if (tokens.cost) out.push(this.field("API COST", `$${tokens.cost.toFixed(3)}`, inner, "muted"));
893
+ }
765
894
 
766
895
  // SYNC: the golem's obedience = tool success rate (CHESED ✓ / GEBURAH ✗)
767
896
  const sync = syncPercent();
@@ -785,12 +914,15 @@ class MagiPanel implements Component {
785
914
  const usage = liveCtx?.getContextUsage?.();
786
915
  const percent = usage?.percent ?? 0;
787
916
  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) {
917
+ if (!compact && usage?.contextWindow) {
789
918
  out.push(this.field("CONTEXT", `${usage.tokens == null ? "?" : fmtTokens(usage.tokens)} / ${fmtTokens(usage.contextWindow)}`, inner, "muted"));
790
919
  }
920
+ return out;
921
+ }
791
922
 
792
- // performance
793
- out.push(this.sep(inner, "PERFORMANCE"));
923
+ private performanceRows(inner: number, compact: boolean): string[] {
924
+ const th = this.theme;
925
+ const out = [this.sep(inner, "PERFORMANCE")];
794
926
  const live = state.phase !== "idle" && perf.chars > 0;
795
927
  // idle: prefer the value measured by llama-swap itself
796
928
  const tps = live ? liveTps() : swap.srvTps || perf.tps;
@@ -800,6 +932,7 @@ class MagiPanel implements Component {
800
932
  inner,
801
933
  ),
802
934
  );
935
+ if (compact) return out;
803
936
  if (swap.srvPps) out.push(this.field("PROMPT/S", swap.srvPps.toFixed(1), inner, "muted"));
804
937
  // llama-swap's input_tokens are the prompt tokens processed outside the cache
805
938
  const promptTotal = swap.cacheTokens + swap.inputTokens;
@@ -812,47 +945,77 @@ class MagiPanel implements Component {
812
945
  out.push(this.field("DURATION", perf.lastMs ? fmtMs(perf.lastMs) : "—", inner, "muted"));
813
946
  const up = Math.floor((Date.now() - sessionStart) / 60000);
814
947
  out.push(this.field("UPTIME", `${Math.floor(up / 60)}h ${String(up % 60).padStart(2, "0")}m`, inner, "muted"));
948
+ return out;
949
+ }
815
950
 
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")
951
+ /** COST: GPU energy used in the session × price per kWh set with /magi-ui config. */
952
+ private costRow(inner: number): string {
953
+ if (!swap.gpus.length) return this.field("COST", "—", inner, "muted");
954
+ if (ui.kwhPrice === undefined) return this.field("COST", "→ /magi-ui config", inner, "dim");
955
+ return this.field("COST", fmtMoney((swap.energyWh / 1000) * ui.kwhPrice), inner, "warning");
956
+ }
957
+
958
+ private swapRows(inner: number, compact: boolean): string[] {
959
+ if (!swap.base) return [];
960
+ const th = this.theme;
961
+ const out = [this.sep(inner, "LLAMA-SWAP")];
962
+ const st =
963
+ swap.state === "ready"
964
+ ? th.fg("success", "IN VRAM")
965
+ : swap.state === "asleep"
966
+ ? th.fg("muted", "ASLEEP")
822
967
  : swap.state === "error"
823
968
  ? th.fg("error", "OFFLINE")
824
969
  : 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"));
970
+ const load = swap.state === "ready" && swap.loadMs ? th.fg("dim", ` load ${fmtMs(swap.loadMs)}`) : "";
971
+ out.push(this.frameLine(` ${th.fg("dim", "STATUS".padEnd(9))}${st}${load}`, inner));
972
+ if (compact) return [...out, this.costRow(inner)];
973
+ if (swap.state === "error") out.push(this.frameLine(" " + th.fg("error", swap.error), inner));
974
+ const gib = (n: number) => n / 2 ** 30;
975
+ for (const [i, g] of swap.gpus.entries()) {
976
+ const pct = g.memTotal ? (g.memUsed / g.memTotal) * 100 : 0;
977
+ out.push(
978
+ this.frameLine(
979
+ ` ${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`)}`,
980
+ inner,
981
+ ),
982
+ );
983
+ out.push(
984
+ this.frameLine(
985
+ ` ${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`)}`,
986
+ inner,
987
+ ),
988
+ );
845
989
  }
990
+ if (swap.ramTotal) out.push(this.field("RAM", `${gib(swap.ramUsed).toFixed(1)} / ${Math.round(gib(swap.ramTotal))}G`, inner, "muted"));
991
+ if (swap.gpus.length) out.push(this.field("ENERGY", `${(swap.energyWh / 1000).toFixed(3)} kWh`, inner, "muted"));
992
+ out.push(this.costRow(inner));
993
+ return out;
994
+ }
846
995
 
847
- out.push(b("└" + "─".repeat(inner) + "┘"));
848
- return out.map((l) => truncateToWidth(l, width));
996
+ render(width: number): string[] {
997
+ const inner = Math.max(24, width - 2);
998
+ const now = Date.now();
999
+ if (!this.cache || this.cache.inner !== inner || this.cache.compact !== ui.compact || now - this.cache.at > 500) {
1000
+ const c = ui.compact;
1001
+ this.cache = {
1002
+ at: now,
1003
+ inner,
1004
+ compact: c,
1005
+ lines: [...this.sessionRows(inner, c), ...this.telemetryRows(inner, c), ...this.performanceRows(inner, c), ...this.swapRows(inner, c)],
1006
+ };
1007
+ }
1008
+ const bottom = this.theme.fg("border", "└" + "─".repeat(inner) + "┘");
1009
+ const lines = [...this.councilSection(inner), ...this.cache.lines];
1010
+ // fullscreen: keep the frame going down to the bottom of the terminal
1011
+ const target = (this.fillHeight?.() ?? 0) - 1;
1012
+ while (lines.length < target) lines.push(this.frameLine("", inner));
1013
+ return [...lines, bottom].map((l) => truncateToWidth(l, width));
849
1014
  }
850
1015
  }
851
1016
 
852
1017
  /* ─────────────────────────────────────────────── MAGI deliberation ── */
853
1018
 
854
- type Vote = "APPROVE" | "CONDITIONAL" | "REJECT";
855
-
856
1019
  function voteTone(v: string | null | undefined): "success" | "warning" | "error" {
857
1020
  return v === "APPROVE" ? "success" : v === "CONDITIONAL" ? "warning" : "error";
858
1021
  }
@@ -925,13 +1088,13 @@ interface Deliberation {
925
1088
  tally: number;
926
1089
  }
927
1090
 
928
- /* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, … } ── */
1091
+ /* ── config: ~/.pi/agent/magi.json → { "MELCHIOR": { "model": "provider/id", "thinking": "low" }, …, "ui": { "compact": true, "kwhPrice": 0.3 } } ── */
929
1092
 
930
1093
  interface MagiUnitConfig {
931
1094
  model?: string;
932
1095
  thinking?: string;
933
1096
  }
934
- type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>>;
1097
+ type MagiConfig = Partial<Record<MagiUnit, MagiUnitConfig>> & { ui?: { compact?: boolean; kwhPrice?: number; currency?: Currency } };
935
1098
 
936
1099
  const MAGI_CONFIG_PATH = join(homedir(), ".pi", "agent", "magi.json");
937
1100
 
@@ -943,6 +1106,10 @@ function loadMagiConfig(): MagiConfig {
943
1106
  }
944
1107
  }
945
1108
 
1109
+ function saveMagiConfig(cfg: MagiConfig): void {
1110
+ writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
1111
+ }
1112
+
946
1113
  function parseVote(text: string): Vote {
947
1114
  const m = /VOTE:\s*\**\s*(APPROVE|CONDITIONAL|REJECT)/i.exec(text);
948
1115
  return m ? (m[1]!.toUpperCase() as Vote) : "CONDITIONAL";
@@ -981,6 +1148,22 @@ function conversationExcerpt(ctx: ExtensionContext, maxChars = 6000): string {
981
1148
  return joined.length > maxChars ? "…" + joined.slice(-maxChars) : joined;
982
1149
  }
983
1150
 
1151
+ const REVIEW_MAX_CHARS = 24_000;
1152
+
1153
+ /** The pending changes for /magi review: tracked changes against HEAD plus the names of untracked files. */
1154
+ async function pendingChanges(cwd: string): Promise<{ diff: string; untracked: string[] }> {
1155
+ const git = (args: string[]) => promisify(execFile)("git", args, { cwd, maxBuffer: 32 * 1024 * 1024 }).then((r) => r.stdout);
1156
+ let diff: string;
1157
+ try {
1158
+ diff = await git(["diff", "HEAD"]);
1159
+ } catch {
1160
+ // no commits yet: staged + unstaged
1161
+ diff = (await git(["diff", "--cached"])) + (await git(["diff"]));
1162
+ }
1163
+ const untracked = (await git(["ls-files", "--others", "--exclude-standard"]).catch(() => "")).split("\n").filter(Boolean);
1164
+ return { diff, untracked };
1165
+ }
1166
+
984
1167
  /** Common function words per language, used to name the reply language explicitly. */
985
1168
  const LANGUAGE_HINTS: readonly [string, readonly string[]][] = [
986
1169
  ["Italian", ["il", "lo", "la", "gli", "di", "che", "per", "non", "una", "con", "sono", "come", "perché", "è", "dovrei", "meglio", "mettiamo", "questo", "quale"]],
@@ -1004,13 +1187,13 @@ function guessLanguage(text: string): string | undefined {
1004
1187
  }
1005
1188
 
1006
1189
  /** 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 {
1190
+ function councilPrompt(project: string, context: string, question: string, contextLabel = "Recent conversation (context only, may be empty)"): string {
1008
1191
  const lang = guessLanguage(question);
1009
1192
  const reminder = lang
1010
1193
  ? `(Write your whole answer in ${lang}, even if technical terms in the question are English.)`
1011
1194
  : "(Write your whole answer in the language of this question, even if technical terms in it are English.)";
1012
1195
  return (
1013
- `Project: ${project}\n\nRecent conversation (context only, may be empty):\n<conversation>\n${excerpt}\n</conversation>\n\n` +
1196
+ `Project: ${project}\n\n${contextLabel}:\n<context>\n${context}\n</context>\n\n` +
1014
1197
  `Question for the MAGI:\n${question}\n\n${reminder}`
1015
1198
  );
1016
1199
  }
@@ -1089,11 +1272,10 @@ function buildDeliberationView(
1089
1272
  out.push(row(" " + theme.fg("muted", question)));
1090
1273
  out.push(row(""));
1091
1274
 
1092
- // Pending units flicker until their model answers.
1093
- const spin = ["◐", "◓", "◑", "◒"][tick % 4]!;
1275
+ // Pending units flicker at random until their model answers.
1094
1276
  const units: UnitView[] = MAGI_UNITS.map((name, i) => {
1095
1277
  const o = opinions[i];
1096
- if (!o) return { name, status: `${spin} ···`, tone: "accent", lit: tick % 2 === 0 };
1278
+ if (!o) return { name, status: "···", tone: "accent", lit: flicker(tick, i) };
1097
1279
  if (!o.vote) return { name, status: "ERROR", tone: "error", lit: true };
1098
1280
  return { name, status: o.vote === "CONDITIONAL" ? "COND." : o.vote, tone: voteTone(o.vote), lit: true };
1099
1281
  });
@@ -1131,6 +1313,29 @@ function buildDeliberationView(
1131
1313
  };
1132
1314
  }
1133
1315
 
1316
+ /** A read-only boxed report (used by /magi-ui status); any key closes it. */
1317
+ function buildReportView(theme: Theme, lines: string[], close: () => void) {
1318
+ return {
1319
+ render(width: number): string[] {
1320
+ const inner = Math.max(40, Math.min(width - 2, 96));
1321
+ const row = (s: string) => {
1322
+ const t = truncateToWidth(" " + s, inner);
1323
+ return theme.fg("accent", "│") + t + " ".repeat(Math.max(0, inner - visibleWidth(t))) + theme.fg("accent", "│");
1324
+ };
1325
+ return [
1326
+ theme.fg("accent", "┌" + "─".repeat(inner) + "┐"),
1327
+ ...lines.map(row),
1328
+ theme.fg("accent", "└" + "─".repeat(inner) + "┘"),
1329
+ theme.fg("dim", " any key: close"),
1330
+ ].map((l) => truncateToWidth(l, width));
1331
+ },
1332
+ handleInput(_data: string) {
1333
+ close();
1334
+ },
1335
+ invalidate() {},
1336
+ };
1337
+ }
1338
+
1134
1339
  async function configureMagi(ctx: ExtensionContext): Promise<void> {
1135
1340
  const cfg = loadMagiConfig();
1136
1341
  const pool = ctx.scopedModels.length ? ctx.scopedModels.map((s) => s.model) : ctx.modelRegistry.getAvailable();
@@ -1145,7 +1350,7 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1145
1350
  }
1146
1351
  cfg[magi.unit] = { ...cfg[magi.unit], model: pick === SESSION ? undefined : pick };
1147
1352
  }
1148
- writeFileSync(MAGI_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
1353
+ saveMagiConfig(cfg);
1149
1354
  ctx.ui.notify(`MAGI config saved to ${MAGI_CONFIG_PATH}`, "info");
1150
1355
  }
1151
1356
 
@@ -1154,11 +1359,12 @@ async function configureMagi(ctx: ExtensionContext): Promise<void> {
1154
1359
  /**
1155
1360
  * The footer's left side, one animation per real state of the agent:
1156
1361
  * - seals breaking / seventh seal opened → context compaction
1157
- * - golem EMET / MET → tool running / tool failed
1362
+ * - golem EMET / MET → tool running (with its file or command) / tool failed
1158
1363
  * - light pulsing in the upper triad → thinking
1159
1364
  * - light descending to Malkuth → streaming the answer
1160
1365
  * - light ascending from Malkuth → loading the model into VRAM
1161
- * - at rest in Malkuth → idle
1366
+ * - the sixth seal → context close to compaction
1367
+ * - asleep / at rest in Malkuth → model unloaded / idle
1162
1368
  */
1163
1369
  function footerLeft(th: Theme, now = Date.now()): string {
1164
1370
  const dim = (s: string) => th.fg("dim", s);
@@ -1190,14 +1396,17 @@ function footerLeft(th: Theme, now = Date.now()): string {
1190
1396
  const tally = th.fg("success", ` CHESED ✓${state.toolOk}`) + th.fg(state.toolFail ? "error" : "dim", ` GEBURAH ✗${state.toolFail}`);
1191
1397
  const syncText = sync === null ? "" : dim(" · sync ") + th.fg(syncTone(sync), `${sync.toFixed(0)}%`);
1192
1398
  if (now - state.lastFailAt < FAIL_FLASH_MS) {
1193
- return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + dim(" ·") + tally + syncText;
1399
+ const target = state.lastFailTarget ? dim(" ") + th.fg("error", truncateToWidth(state.lastFailTarget, 40)) : "";
1400
+ return th.fg("error", "✗ GOLEM · MET") + dim(" · ") + th.fg("error", `${state.lastFailTool} failed`) + target + dim(" ·") + tally + syncText;
1194
1401
  }
1195
1402
  const hammer = ["▚", "▞"][step % 2]!;
1403
+ const target = state.toolTarget ? dim(" ") + th.fg("muted", truncateToWidth(state.toolTarget, 40)) : "";
1196
1404
  return (
1197
1405
  th.fg("accent", `${hammer} GOLEM · `) +
1198
1406
  th.bold(th.fg("warning", "EMET")) +
1199
1407
  dim(" · ") +
1200
1408
  th.fg("text", state.toolName || "tool") +
1409
+ target +
1201
1410
  dim(` ${secsSince(state.phaseSince, now)}s ·`) +
1202
1411
  tally +
1203
1412
  syncText
@@ -1233,6 +1442,19 @@ function footerLeft(th: Theme, now = Date.now()): string {
1233
1442
  dim(` · loading model into VRAM ${secsSince(swap.since, now)}s`)
1234
1443
  );
1235
1444
  }
1445
+ const percent = liveCtx?.getContextUsage?.()?.percent ?? 0;
1446
+ if (percent >= SIXTH_SEAL_PERCENT) {
1447
+ const cmd = state.hasSmartCompact ? "/smart-compact" : "/compact";
1448
+ return (
1449
+ th.fg(step % 4 < 2 ? "error" : "warning", "⚠ SIXTH SEAL") +
1450
+ dim(` · context ${percent.toFixed(0)}% · compact now with `) +
1451
+ th.fg("text", cmd) +
1452
+ dim(" before it happens mid-task")
1453
+ );
1454
+ }
1455
+ if (swap.state === "asleep") {
1456
+ return th.fg("muted", "○ MALKUTH") + dim(" · model asleep in llama-swap · type to wake it");
1457
+ }
1236
1458
  const last = state.lastRunMs ? ` · last run ${fmtMs(state.lastRunMs)}` : "";
1237
1459
  return th.fg("success", "○ ") + th.fg("muted", "MALKUTH") + dim(` · the kingdom · at rest${last}`);
1238
1460
  }
@@ -1265,14 +1487,30 @@ function buildFooter(tui: TUI, theme: Theme, footerData: any) {
1265
1487
 
1266
1488
  /* ───────────────────────────────────────────────────────── extension ── */
1267
1489
 
1490
+ const BUSY_POLL_MS = 3_000;
1491
+ const IDLE_POLL_MS = 30_000;
1492
+ /** pi rewrites the window title on its own (right after startup, on session info changes), so ours is re-applied. */
1493
+ const TITLE_REFRESH_MS = 2_000;
1494
+
1495
+ /** Window title: "π - Magi - <working directory>", prefixed with ✓ after a long run until you are back. */
1496
+ function windowTitle(cwd: string, done = false): string {
1497
+ const home = homedir();
1498
+ const dir = cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
1499
+ return `${done ? "✓ " : ""}π - Magi - ${dir}`;
1500
+ }
1501
+
1268
1502
  export default function (pi: ExtensionAPI) {
1269
1503
  let chrome = true;
1270
1504
  let panelEnabled = true;
1271
1505
  let tuiRef: TUI | undefined;
1272
1506
  let panelHandle: OverlayHandle | undefined;
1273
1507
  let panel: MagiPanel | undefined;
1508
+ let titleDone = false;
1274
1509
 
1275
- const repaint = () => tuiRef?.requestRender();
1510
+ const repaint = () => {
1511
+ panel?.invalidate();
1512
+ tuiRef?.requestRender();
1513
+ };
1276
1514
 
1277
1515
  let footerDataRef: any;
1278
1516
  let wrappedRoot: any; // pi's original root, when the panel is a fullscreen column
@@ -1286,6 +1524,7 @@ export default function (pi: ExtensionAPI) {
1286
1524
  // ponytail: reads TuiAltScreen's private layoutRoot field; if pi renames it, falls back to the overlay.
1287
1525
  const t = tuiRef as any;
1288
1526
  if (t.mode === "fullscreen" && t.layoutRoot && typeof t.setLayoutRoot === "function") {
1527
+ panel.fillHeight = () => process.stdout.rows ?? 0;
1289
1528
  wrappedRoot = t.layoutRoot;
1290
1529
  t.setLayoutRoot(
1291
1530
  new HStack(
@@ -1347,7 +1586,7 @@ export default function (pi: ExtensionAPI) {
1347
1586
  return buildFooter(tui, theme, footerData);
1348
1587
  });
1349
1588
  ctx.ui.setWorkingIndicator({ frames: pulseFrames(ctx.ui.theme), intervalMs: 110 });
1350
- ctx.ui.setTitle("MAGI");
1589
+ ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
1351
1590
  };
1352
1591
 
1353
1592
  pi.registerEntryRenderer("magi-verdict", (entry: any, _options: any, theme: Theme) => {
@@ -1371,15 +1610,54 @@ export default function (pi: ExtensionAPI) {
1371
1610
  });
1372
1611
 
1373
1612
  let metricsTimer: ReturnType<typeof setInterval> | undefined;
1613
+ let unsubscribeInput: (() => void) | undefined;
1614
+ let lastPoll = 0;
1615
+ let titleTimer: ReturnType<typeof setInterval> | undefined;
1616
+ /** Tools currently executing, by call id: the golem shows one of them, and leaves when none is left. */
1617
+ const runningTools = new Map<string, { name: string; target: string }>();
1374
1618
 
1375
1619
  pi.on("session_start", async (_event, ctx) => {
1376
1620
  liveCtx = ctx;
1621
+ recountSession(ctx);
1622
+ state.hasSmartCompact = pi.getCommands().some((c) => c.name.replace(/^\//, "") === "smart-compact");
1623
+ const cfg = loadMagiConfig();
1624
+ ui.compact = cfg.ui?.compact ?? false;
1625
+ ui.kwhPrice = cfg.ui?.kwhPrice;
1626
+ ui.currency = cfg.ui?.currency === "USD" ? "USD" : "EUR";
1377
1627
  if (ctx.mode !== "tui") return;
1378
1628
  applyChrome(ctx);
1379
- // load the model into VRAM now (animated in the panel and footer) and keep GPU stats fresh
1629
+ // load the model into VRAM now (animated in the panel and footer)
1380
1630
  void preloadModel(ctx);
1381
- metricsTimer ??= setInterval(() => void refreshSwapMetrics(), 3000);
1631
+ // GPU stats every 3s while something happens, every 30s when idle
1632
+ metricsTimer ??= setInterval(() => {
1633
+ const now = Date.now();
1634
+ if (!animating(now) && now - lastPoll < IDLE_POLL_MS) return;
1635
+ lastPoll = now;
1636
+ void refreshSwapMetrics()
1637
+ .then(refreshSwapRunning)
1638
+ .then(repaint);
1639
+ }, BUSY_POLL_MS);
1382
1640
  metricsTimer.unref?.();
1641
+ // back at the keyboard: clear the ✓ from the title, wake a model llama-swap unloaded
1642
+ unsubscribeInput ??= ctx.ui.onTerminalInput(() => {
1643
+ if (titleDone) {
1644
+ titleDone = false;
1645
+ ctx.ui.setTitle(windowTitle(ctx.cwd));
1646
+ }
1647
+ if (swap.state === "asleep" && liveCtx) void preloadModel(liveCtx);
1648
+ return undefined;
1649
+ });
1650
+ // ponytail: re-applies the title every 2s because pi overwrites it without telling extensions
1651
+ titleTimer ??= setInterval(() => {
1652
+ if (chrome) ctx.ui.setTitle(windowTitle(ctx.cwd, titleDone));
1653
+ }, TITLE_REFRESH_MS);
1654
+ titleTimer.unref?.();
1655
+ });
1656
+
1657
+ pi.on("session_tree", async (_event, ctx) => {
1658
+ liveCtx = ctx;
1659
+ recountSession(ctx);
1660
+ repaint();
1383
1661
  });
1384
1662
 
1385
1663
  pi.on("model_select", async (event, ctx) => {
@@ -1390,6 +1668,10 @@ export default function (pi: ExtensionAPI) {
1390
1668
  pi.on("session_shutdown", async () => {
1391
1669
  clearInterval(metricsTimer);
1392
1670
  metricsTimer = undefined;
1671
+ unsubscribeInput?.();
1672
+ unsubscribeInput = undefined;
1673
+ clearInterval(titleTimer);
1674
+ titleTimer = undefined;
1393
1675
  hidePanel();
1394
1676
  });
1395
1677
 
@@ -1419,7 +1701,15 @@ export default function (pi: ExtensionAPI) {
1419
1701
  const t = e?.type;
1420
1702
  if (t === "thinking_start" || t === "thinking_delta") setPhase("thinking");
1421
1703
  else if (t === "text_start" || t === "text_delta") setPhase("responding");
1422
- else if (t === "toolcall_start" || t === "toolcall_delta") setPhase("tool");
1704
+ else if (t === "toolcall_start" || t === "toolcall_delta") {
1705
+ // the model is writing a tool call: the golem shows which tool it prepares, not the previous one
1706
+ setPhase("tool");
1707
+ const call = e.partial?.content?.[e.contentIndex];
1708
+ if (call?.type === "toolCall") {
1709
+ state.toolName = call.name ?? "";
1710
+ state.toolTarget = toolTarget(call.arguments);
1711
+ }
1712
+ }
1423
1713
  if (typeof e?.delta === "string" && e.delta) {
1424
1714
  if (!perf.first) perf.first = Date.now();
1425
1715
  perf.chars += e.delta.length;
@@ -1427,20 +1717,23 @@ export default function (pi: ExtensionAPI) {
1427
1717
  });
1428
1718
 
1429
1719
  pi.on("message_end", async (event) => {
1430
- if (event.message.role !== "assistant" || !perf.start) return;
1720
+ if (event.message.role !== "assistant") return;
1431
1721
  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);
1722
+ addUsage(m);
1723
+ if (perf.start) {
1724
+ const end = Date.now();
1725
+ perf.lastMs = end - perf.start;
1726
+ if (perf.first) {
1727
+ perf.ttft = perf.first - perf.start;
1728
+ const genS = (end - perf.first) / 1000;
1729
+ const out = m.usage?.output || perf.chars / 4;
1730
+ if (genS > 0.05 && out) {
1731
+ perf.tps = out / genS;
1732
+ perf.peakTps = Math.max(perf.peakTps, perf.tps);
1733
+ }
1441
1734
  }
1735
+ perf.start = 0;
1442
1736
  }
1443
- perf.start = 0;
1444
1737
  repaint();
1445
1738
  // llama-swap records the request once it completes
1446
1739
  setTimeout(() => void refreshSwapActivity().then(repaint), 300);
@@ -1448,20 +1741,37 @@ export default function (pi: ExtensionAPI) {
1448
1741
 
1449
1742
  pi.on("tool_execution_start", async (event, ctx) => {
1450
1743
  liveCtx = ctx;
1744
+ const tool = { name: event.toolName ?? "", target: toolTarget(event.args) };
1745
+ runningTools.set(event.toolCallId, tool);
1451
1746
  setPhase("tool");
1452
- state.toolName = event.toolName ?? "";
1747
+ state.toolName = tool.name;
1748
+ state.toolTarget = tool.target;
1453
1749
  repaint();
1454
1750
  });
1455
1751
 
1456
1752
  // CHESED (mercy) counts what worked, GEBURAH (severity) what failed; a failure erases the golem's aleph.
1457
1753
  pi.on("tool_execution_end", async (event) => {
1754
+ const tool = runningTools.get(event.toolCallId);
1755
+ runningTools.delete(event.toolCallId);
1458
1756
  if (event.isError) {
1459
1757
  state.toolFail++;
1460
1758
  state.lastFailAt = Date.now();
1461
- state.lastFailTool = event.toolName ?? "tool";
1759
+ state.lastFailTool = event.toolName ?? tool?.name ?? "tool";
1760
+ state.lastFailTarget = tool?.target ?? "";
1462
1761
  } else {
1463
1762
  state.toolOk++;
1464
1763
  }
1764
+ const next = [...runningTools.values()][0];
1765
+ if (next) {
1766
+ // parallel tools: the golem moves on to one still running
1767
+ state.toolName = next.name;
1768
+ state.toolTarget = next.target;
1769
+ } else {
1770
+ // all tools done: the model now reads their results and thinks about the next step
1771
+ setPhase("thinking");
1772
+ state.toolName = "";
1773
+ state.toolTarget = "";
1774
+ }
1465
1775
  repaint();
1466
1776
  });
1467
1777
 
@@ -1470,19 +1780,26 @@ export default function (pi: ExtensionAPI) {
1470
1780
  if (state.runStart) state.lastRunMs = Date.now() - state.runStart;
1471
1781
  state.runStart = 0;
1472
1782
  setPhase("idle");
1783
+ runningTools.clear();
1473
1784
  state.toolName = "";
1474
- if (ctx.mode === "tui" && chrome) ctx.ui.setWorkingMessage();
1785
+ state.toolTarget = "";
1786
+ if (ctx.mode === "tui" && chrome) {
1787
+ ctx.ui.setWorkingMessage();
1788
+ // a long run just finished: mark the window title until you touch the keyboard
1789
+ if (state.lastRunMs > DONE_TITLE_AFTER_MS) {
1790
+ titleDone = true;
1791
+ ctx.ui.setTitle(windowTitle(ctx.cwd, true));
1792
+ }
1793
+ }
1475
1794
  repaint();
1476
1795
  });
1477
1796
 
1478
1797
  // The seven seals: compaction breaks them, and the context is reborn.
1479
1798
  // 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
1799
  pi.on("session_before_compact", async () => {
1483
1800
  state.compacting = true;
1484
1801
  state.compactSince = Date.now();
1485
- state.compactBy = hasSmartCompact() ? "smart-compact" : "pi native";
1802
+ state.compactBy = state.hasSmartCompact ? "smart-compact" : "pi native";
1486
1803
  repaint();
1487
1804
  });
1488
1805
 
@@ -1490,7 +1807,7 @@ export default function (pi: ExtensionAPI) {
1490
1807
  state.compacting = false;
1491
1808
  state.rebornAt = Date.now();
1492
1809
  // 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";
1810
+ state.compactBy = event.fromExtension ? (state.hasSmartCompact ? "smart-compact" : "extension") : "pi native";
1494
1811
  repaint();
1495
1812
  });
1496
1813
 
@@ -1499,52 +1816,82 @@ export default function (pi: ExtensionAPI) {
1499
1816
  repaint();
1500
1817
  });
1501
1818
 
1819
+ /** Runs the council on a question with some context, shows the deliberation, stores the verdict. */
1820
+ async function runCouncil(ctx: ExtensionContext, question: string, context: string, contextLabel?: string): Promise<void> {
1821
+ const cfg = loadMagiConfig();
1822
+ const project = (ctx.cwd ?? "").split("/").filter(Boolean).pop() ?? "";
1823
+ const prompt = councilPrompt(project, context, question, contextLabel);
1824
+
1825
+ const controller = new AbortController();
1826
+ const opinions: (MagiOpinion | undefined)[] = MAGI.map(() => undefined);
1827
+ let finished = false;
1828
+ const all = Promise.all(
1829
+ MAGI.map((_, i) =>
1830
+ askMagi(ctx, i, prompt, cfg, controller.signal).then((o) => {
1831
+ opinions[i] = o;
1832
+ return o;
1833
+ }),
1834
+ ),
1835
+ ).finally(() => {
1836
+ finished = true;
1837
+ });
1838
+
1839
+ if (ctx.mode === "tui") {
1840
+ const cancelled = await ctx.ui.custom<boolean>((tui, theme, _keys, done) =>
1841
+ buildDeliberationView(tui, theme, question, opinions, () => finished, done),
1842
+ );
1843
+ if (cancelled) {
1844
+ controller.abort();
1845
+ ctx.ui.notify("MAGI deliberation aborted", "warning");
1846
+ return;
1847
+ }
1848
+ }
1849
+
1850
+ const final = await all;
1851
+ const { verdict, tally } = tallyVerdict(final.map((o) => o.vote));
1852
+ pi.appendEntry("magi-verdict", { question, opinions: final, verdict, tally } satisfies Deliberation);
1853
+ state.lastCouncil = { verdict, tally, question };
1854
+ repaint();
1855
+ }
1856
+
1502
1857
  pi.registerCommand("magi", {
1503
- description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi config assigns a model to each",
1858
+ description: "Ask the three MAGI (pragmatist, guardian, visionary); /magi review [focus] judges the git diff; /magi config assigns models",
1504
1859
  handler: async (args, ctx) => {
1505
1860
  const arg = args.trim();
1506
1861
  if (arg === "config") return configureMagi(ctx);
1507
1862
 
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");
1863
+ if (arg === "review" || arg.startsWith("review ")) {
1864
+ const focus = arg.slice("review".length).trim();
1865
+ let changes: { diff: string; untracked: string[] };
1866
+ try {
1867
+ changes = await pendingChanges(ctx.cwd);
1868
+ } catch {
1869
+ ctx.ui.notify("MAGI review needs a git repository", "error");
1536
1870
  return;
1537
1871
  }
1872
+ if (!changes.diff.trim() && !changes.untracked.length) {
1873
+ ctx.ui.notify("Nothing to review: no changes against HEAD", "info");
1874
+ return;
1875
+ }
1876
+ const diff =
1877
+ changes.diff.length > REVIEW_MAX_CHARS
1878
+ ? changes.diff.slice(0, REVIEW_MAX_CHARS) + `\n… diff truncated (${changes.diff.length} chars in total)`
1879
+ : changes.diff;
1880
+ const untracked = changes.untracked.length ? `\n\nUntracked files (content not shown):\n${changes.untracked.join("\n")}` : "";
1881
+ const question = focus
1882
+ ? `Review these pending changes before committing, focusing on: ${focus}`
1883
+ : "Review these pending changes before committing: are they ready to commit, and what must change first?";
1884
+ return runCouncil(ctx, question, "```diff\n" + diff + "\n```" + untracked, "Pending changes (git diff HEAD)");
1538
1885
  }
1539
1886
 
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);
1887
+ const question = arg || (await ctx.ui.input("Question for the MAGI:", "should we …?"))?.trim() || "";
1888
+ if (!question) return;
1889
+ return runCouncil(ctx, question, conversationExcerpt(ctx));
1543
1890
  },
1544
1891
  });
1545
1892
 
1546
1893
  pi.registerCommand("magi-ui", {
1547
- description: "MAGI chrome: enable the theme, or manage chrome and side panel (on|off|panel)",
1894
+ description: "MAGI chrome: enable the theme, or manage it (on|off|panel|compact|status|config)",
1548
1895
  handler: async (args, ctx) => {
1549
1896
  liveCtx = ctx;
1550
1897
  const arg = args.trim().toLowerCase();
@@ -1556,6 +1903,59 @@ export default function (pi: ExtensionAPI) {
1556
1903
  ctx.ui.notify(`Side panel ${panelEnabled ? "enabled" : "disabled"}`, "info");
1557
1904
  return;
1558
1905
  }
1906
+ if (arg === "compact") {
1907
+ ui.compact = !ui.compact;
1908
+ const cfg = loadMagiConfig();
1909
+ saveMagiConfig({ ...cfg, ui: { ...cfg.ui, compact: ui.compact } });
1910
+ repaint();
1911
+ ctx.ui.notify(`Side panel ${ui.compact ? "compact" : "detailed"}`, "info");
1912
+ return;
1913
+ }
1914
+ if (arg === "config") {
1915
+ const currency = await ctx.ui.select(`Currency for COST (current: ${ui.currency})`, ["EUR", "USD"]);
1916
+ if (!currency) return;
1917
+ const current = ui.kwhPrice !== undefined ? ` (current: ${ui.kwhPrice})` : "";
1918
+ const raw = (await ctx.ui.input(`Electricity price per kWh in ${currency}${current}:`, "0.30"))?.trim();
1919
+ if (raw === undefined) return;
1920
+ const price = raw === "" && ui.kwhPrice !== undefined ? ui.kwhPrice : Number(raw.replace(",", "."));
1921
+ if (raw === "" && ui.kwhPrice === undefined) {
1922
+ ctx.ui.notify("No price entered: COST unchanged", "warning");
1923
+ return;
1924
+ }
1925
+ if (!Number.isFinite(price) || price < 0) {
1926
+ ctx.ui.notify(`Invalid price: "${raw}"`, "error");
1927
+ return;
1928
+ }
1929
+ ui.currency = currency === "USD" ? "USD" : "EUR";
1930
+ ui.kwhPrice = price;
1931
+ const cfg = loadMagiConfig();
1932
+ saveMagiConfig({ ...cfg, ui: { ...cfg.ui, currency: ui.currency, kwhPrice: price } });
1933
+ repaint();
1934
+ ctx.ui.notify(`COST: ${fmtMoney(price)} per kWh`, "info");
1935
+ return;
1936
+ }
1937
+ if (arg === "status") {
1938
+ if (!swap.base) {
1939
+ ctx.ui.notify("/magi-ui status needs a llama-swap session model", "warning");
1940
+ return;
1941
+ }
1942
+ let report: { data?: ActivityRow[]; total?: number };
1943
+ try {
1944
+ report = (await (await swapGet(`/api/metrics/activity?limit=${ACTIVITY_REPORT_ROWS}`, 15_000)).json()) as typeof report;
1945
+ } catch (err) {
1946
+ ctx.ui.notify(`llama-swap status failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1947
+ return;
1948
+ }
1949
+ const rows = report.data ?? [];
1950
+ if (!rows.length) {
1951
+ ctx.ui.notify("llama-swap has no recorded requests yet", "info");
1952
+ return;
1953
+ }
1954
+ await ctx.ui.custom<void>((_tui, theme, _keys, done) =>
1955
+ buildReportView(theme, activityReport(theme, rows, report.total ?? rows.length), () => done(undefined)),
1956
+ );
1957
+ return;
1958
+ }
1559
1959
  if (arg === "off" || arg === "on") {
1560
1960
  chrome = arg === "on";
1561
1961
  applyChrome(ctx);
@@ -1570,7 +1970,7 @@ export default function (pi: ExtensionAPI) {
1570
1970
  }
1571
1971
  chrome = true;
1572
1972
  applyChrome(ctx);
1573
- ctx.ui.notify("MAGI online — /magi-ui panel, /magi-ui off", "info");
1973
+ ctx.ui.notify("MAGI online — /magi-ui panel|compact|status|off", "info");
1574
1974
  },
1575
1975
  });
1576
1976
  }