@bismawy/pi-arnative 0.3.10 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Arnative header box & horizontal tab system:
3
3
  * - Round-corner box: left column = the pi block mark (4x8, P accent / i soft) with
4
- * "pi vX" under it; a vertical divider; then the info column = "Arnative vY
5
- * · Welcome back, <User>", the tab menu, the tab data.
4
+ * "pi vX" under it; a vertical divider; then the info column = "Arnative⠙ vY
5
+ * · Welcome back, <User>", the tab menu, the tab data. The Braille cell (U+2819)
6
+ * marks the live brand, matching `Settings - Arnative⠙`.
6
7
  * - Greeting = OS account name (first letter capitalized). The tab box has no label;
7
8
  * its column dividers join the borders with ┬/┴ (full height).
8
9
  * - Interactive tab menu (mouse click): Model + Context/Skills/Extensions/Shortcut
@@ -31,8 +32,8 @@ import {
31
32
  type TuiMouseEventResult,
32
33
  } from "@earendil-works/pi-tui";
33
34
  import type { BoxTheme } from "../lib/box.ts";
34
- import { renderBoxLines } from "../lib/box.ts";
35
- import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
35
+ import { BRAND, renderBoxLines } from "../lib/box.ts";
36
+ import { getActiveTheme, setActiveTheme, softOf, stripAnsi, themeOf } from "../lib/ansi.ts";
36
37
  import { capitalize, expandKeyName, modelDisplayParts } from "../lib/format.ts";
37
38
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
38
39
  import { HEADER_PRESET_KEY as HEADER_PRESET_CONFIG_KEY, loadChoice, saveChoice } from "../lib/preset-store.ts";
@@ -55,6 +56,9 @@ export const SECTION_ICONS: Record<TabKey, string> = {
55
56
  // nf-cod-warning, width 1 — prefixes a pi diagnostic block's compact header.
56
57
  const DIAGNOSTIC_ICON = "\uea6c";
57
58
 
59
+ // The header's brand line shares the one glyph with every `/arnative` screen title.
60
+ export const HEADER_BRAND = `Arnative${BRAND}`;
61
+
58
62
  export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
59
63
 
60
64
  const MODEL_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentModel");
@@ -74,6 +78,19 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
74
78
  return text;
75
79
  }
76
80
 
81
+ /**
82
+ * Painter for the header's soft tier. Pi 1.2.0 rejects a custom `accentSoft` key in
83
+ * `colors`, so `fgFirst(["accentSoft", …])` silently degrades to plain text there and
84
+ * the two-tone logo/header look flattens. `softOf` derives the same tint from the
85
+ * resolved accent; a theme that genuinely defines `accentSoft` still wins first.
86
+ */
87
+ export function softFirst(th: Themeish, fallbacks: string[], text: string): string {
88
+ const soft = softOf(th as never);
89
+ const out = soft(text);
90
+ if (out !== text) return out;
91
+ return fgFirst(th, fallbacks, text);
92
+ }
93
+
77
94
  /**
78
95
  * Collapsed body of a pi startup section ("[Skills]\n a, b"). pi 0.99 dropped
79
96
  * ExpandableText's own getCollapsedText/getExpandedText fields for a `build` callback
@@ -275,7 +292,7 @@ export function centerLine(text: string, width: number, side = "│"): string {
275
292
  // bar, column 3) in the theme soft — same P/i split as the old wordmark.
276
293
  export function buildLogoLines(th: Themeish): string[] {
277
294
  const a = (s: string) => fgFirst(th, ["accent"], s);
278
- const t = (s: string) => fgFirst(th, ["accentSoft", "text"], s);
295
+ const t = (s: string) => softFirst(th, ["accentSoft", "text"], s);
279
296
  return [
280
297
  `${a("██████")} `,
281
298
  `${a("██ ██")} `,
@@ -479,7 +496,7 @@ export class ArnativeHeader implements Component {
479
496
  const side = fgFirst(th, ["dim"], "│");
480
497
 
481
498
  const infoLines: string[] = [];
482
- const brand = (name: string) => fgFirst(th, ["accent", "accentSoft"], `\x1b[1m${name}\x1b[22m`);
499
+ const brand = (name: string) => fgFirst(th, ["accent"], `\x1b[1m${name}\x1b[22m`);
483
500
  const dim = (text: string) => fgFirst(th, ["dim"], text);
484
501
 
485
502
  // Left column: the mark with the pi version under it (no gap). The column is as
@@ -507,10 +524,11 @@ export class ArnativeHeader implements Component {
507
524
  fgFirst(th, ["dim"], `${l}${"─".repeat(jx - 1)}${mid}${"─".repeat(Math.max(0, width - jx - 2))}${r}`);
508
525
  const out: string[] = [border("╭", "┬", "╮")];
509
526
 
510
- // 1. Brand + greeting on one line ("Arnative vX · Welcome back, <User>"; the username
511
- // is capitalized), then a blank line before the tab box.
527
+ // 1. Brand + greeting on one line ("Arnative⠙ vX · Welcome back, <User>"; the username
528
+ // is capitalized), then a blank line before the tab box. The Braille cell marks the
529
+ // live brand, same glyph as `Settings - Arnative⠙`.
512
530
  const arnVer = ARNATIVE_VERSION ? ` ${dim(`v${ARNATIVE_VERSION}`)}` : "";
513
- infoLines.push(`${brand("Arnative")}${arnVer}${dim(" · ")}${dim("Welcome back,")} ${brand(capitalize(deviceUser()))}`);
531
+ infoLines.push(`${brand(HEADER_BRAND)}${arnVer}${dim(" · ")}${dim("Welcome back,")} ${brand(capitalize(deviceUser()))}`);
514
532
 
515
533
  // 2. Tab menu: icon = accent, text = soft (bold when active)
516
534
  const tabs = this.getTabsData(width);
@@ -524,7 +542,7 @@ export class ArnativeHeader implements Component {
524
542
  const isActive = t.key === this.activeTab;
525
543
  const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
526
544
  const labelStyled = isActive
527
- ? fgFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
545
+ ? softFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
528
546
  : fgFirst(th, ["dim"], label);
529
547
  return { key: t.key, plain, formatted: `${iconStyled} ${labelStyled}` };
530
548
  };
@@ -588,19 +606,19 @@ export class ArnativeHeader implements Component {
588
606
  // Model name in soft, its "(Provider)" tail dim.
589
607
  const provSuffix = fullName.match(/(\s*\([^()]*\))$/)?.[1] ?? "";
590
608
  const nameMain = provSuffix ? fullName.slice(0, -provSuffix.length) : fullName;
591
- let line = `${fgFirst(th, ["accentSoft", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${fgFirst(th, ["accentSoft", "text"], `Thinking: ${thinkLevel}`)}`;
609
+ let line = `${softFirst(th, ["accentSoft", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${softFirst(th, ["accentSoft", "text"], `Thinking: ${thinkLevel}`)}`;
592
610
 
593
611
  // All-time usage of that model (same as /usage): ↑... ↓... ...
594
612
  const usage = getModelAllTimeUsage(modelObj?.id);
595
613
  const tokenParts: string[] = [];
596
614
  if (usage.input > 0) {
597
- tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
615
+ tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${softFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
598
616
  }
599
617
  if (usage.output > 0) {
600
- tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
618
+ tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${softFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
601
619
  }
602
620
  if (usage.cacheRead > 0) {
603
- tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
621
+ tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${softFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
604
622
  }
605
623
  if (tokenParts.length > 0) {
606
624
  line += `${sep}${tokenParts.join(" ")}`;
@@ -613,11 +631,11 @@ export class ArnativeHeader implements Component {
613
631
 
614
632
  if (this.activeTab === "Directory") {
615
633
  for (const line of wrapPath(getCwd(this.ctx), infoW)) {
616
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
634
+ infoLines.push(softFirst(th, ["accentSoft", "text"], line));
617
635
  }
618
636
  } else if (this.activeTab === "Shortcut") {
619
637
  for (const line of wrapTokens(SHORTCUTS, infoW)) {
620
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
638
+ infoLines.push(softFirst(th, ["accentSoft", "text"], line));
621
639
  }
622
640
  } else if (activeItems.length === 0) {
623
641
  infoLines.push(
@@ -629,7 +647,7 @@ export class ArnativeHeader implements Component {
629
647
  } else {
630
648
  for (const line of wrapCommaItems(activeItems, infoW)) {
631
649
  // Point 5: selected menu data uses soft only
632
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
650
+ infoLines.push(softFirst(th, ["accentSoft", "text"], line));
633
651
  }
634
652
  }
635
653
 
@@ -79,7 +79,7 @@ export function formatTimeBadge(timeStr: string, th: { fg(color: string, text: s
79
79
  // dark for the rest of the line. Re-apply the bubble bg after every reset and close
80
80
  // it at end of line so it cannot leak. Same pattern as InputBgEditor below.
81
81
  export function repairBubbleBg(line: string, bgOpen: string): string {
82
- if (!bgOpen) return line;
82
+ if (!bgOpen || (!line.includes("\x1b[0m") && !line.includes("\x1b[49m"))) return line;
83
83
  const patched = line.split("\x1b[0m").join(`\x1b[0m${bgOpen}`).split("\x1b[49m").join(`\x1b[49m${bgOpen}`);
84
84
  return patched.endsWith(bgOpen) ? `${patched}\x1b[49m` : patched;
85
85
  }
@@ -39,7 +39,7 @@ import {
39
39
  import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
40
40
  import { homedir } from "node:os";
41
41
  import { renderBoxLines as box } from "../lib/box.ts";
42
- import { stripAnsi, accentSoftOf } from "../lib/ansi.ts";
42
+ import { stripAnsi, softOf } from "../lib/ansi.ts";
43
43
  import { assert, isMain } from "../lib/check.ts";
44
44
  import { expandKeyName } from "../lib/format.ts";
45
45
 
@@ -49,7 +49,16 @@ type TCtx = { isError?: boolean; toolCallId?: string; args?: any; state?: Record
49
49
  type Res = (r: TResult, th: Theme, soft: (s: string) => string, isErr: boolean, expanded: boolean) => string;
50
50
 
51
51
  const cwd = process.cwd();
52
- const TIMINGS = new Map<string, number>(); // duration ms per tool call (one run)
52
+ const MAX_TIMINGS = 200;
53
+ const TIMINGS = new Map<string, number>();
54
+
55
+ export function recordTiming(toolCallId: string, durationMs: number): void {
56
+ if (TIMINGS.size >= MAX_TIMINGS) {
57
+ const oldest = TIMINGS.keys().next().value;
58
+ if (oldest !== undefined) TIMINGS.delete(oldest);
59
+ }
60
+ TIMINGS.set(toolCallId, durationMs);
61
+ }
53
62
 
54
63
  // Running icon 󰔟 󱦠 󱦟: 500ms spin frames from a ticker that only runs while
55
64
  // a tool is active (idle = no wasteful re-renders).
@@ -224,15 +233,17 @@ const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; rend
224
233
  // sequence. Not exported by pi-tui, so match both prefixes.
225
234
  export const isImageLine = (line: string): boolean => line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
226
235
 
227
- /** Drop the leading Spacer only when an image preview follows: its blank tail rows
228
- * are the image's vertical space, and deleting them made the terminal draw the
229
- * image over the rows below (chat text, editor, footer). */
236
+ /** Drop the leading Spacer and the gap between box and image preview:
237
+ * its blank tail rows are kept as the image's vertical space so the
238
+ * terminal draws the image with balanced spacing above and below. */
230
239
  export function stripEdgesKeepingImages(lines: string[]): string[] {
231
240
  const firstImage = lines.findIndex(isImageLine);
232
241
  if (firstImage < 0) return stripBlankEdges(lines);
233
242
  let s = 0;
234
243
  while (s < firstImage && isBlankLine(lines[s])) s++;
235
- return lines.slice(s);
244
+ const head = lines.slice(s, firstImage);
245
+ while (head.length > 0 && isBlankLine(head[head.length - 1]!)) head.pop();
246
+ return [...head, ...lines.slice(firstImage)];
236
247
  }
237
248
 
238
249
  const GAP_KEY = Symbol.for("pi-arnative.toolGapStripped");
@@ -432,7 +443,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
432
443
  ? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
433
444
  : null;
434
445
  return new Lines((width) => {
435
- const soft = accentSoftOf(th);
446
+ const soft = softOf(th);
436
447
  const inner = Math.max(8, width - 4);
437
448
  const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
438
449
  const boxed2 = componentLines(theirs, width - 6);
@@ -586,7 +597,7 @@ function minimal(
586
597
  try {
587
598
  return await tool.execute(toolCallId, params, signal, onUpdate, ctx);
588
599
  } finally {
589
- TIMINGS.set(toolCallId, Math.round(performance.now() - start));
600
+ recordTiming(toolCallId, Math.round(performance.now() - start));
590
601
  ACTIVE--;
591
602
  syncTicker();
592
603
  }
@@ -595,7 +606,7 @@ function minimal(
595
606
  return new Lines((width) => {
596
607
  // Read context.state live (never capture): renderResult may create it after renderCall.
597
608
  if (context.state?.hasResult) return [];
598
- const soft = accentSoftOf(theme);
609
+ const soft = softOf(theme);
599
610
  const inner = Math.max(8, width - 4);
600
611
  const rows = titleRow(
601
612
  theme,
@@ -624,7 +635,7 @@ function minimal(
624
635
  const dur = fmtMs(context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined);
625
636
  const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
626
637
  return new Lines((width) => {
627
- const soft = accentSoftOf(theme);
638
+ const soft = softOf(theme);
628
639
  const inner = Math.max(8, width - 4);
629
640
  const rows = titleRow(
630
641
  theme,
@@ -659,6 +670,7 @@ function arnativeTools(pi: ExtensionAPI) {
659
670
 
660
671
  // Re-render source for the spinner + ticker cleanup when the session closes
661
672
  pi.on("session_start", async (_event, ctx) => {
673
+ TIMINGS.clear();
662
674
  requestRenderFn = () => {
663
675
  try {
664
676
  (ctx.ui as { requestRender?: () => void }).requestRender?.();
@@ -668,6 +680,7 @@ function arnativeTools(pi: ExtensionAPI) {
668
680
  };
669
681
  });
670
682
  pi.on("session_shutdown", async () => {
683
+ TIMINGS.clear();
671
684
  if (TICK) {
672
685
  clearInterval(TICK);
673
686
  TICK = null;
@@ -882,9 +895,9 @@ if (isMain(import.meta.url)) {
882
895
  const itermSeq = "\x1b]1337;File=inline=1:AAAA\x07";
883
896
  const withImage = ["", "╭─╮", "│ ✓ read form_crop.png │", "╰─╯", "", kittySeq, "", ""];
884
897
  const kept = stripEdgesKeepingImages(withImage);
885
- assert(kept.length === 7, "image: only the leading gap dropped, blank tail kept");
898
+ assert(kept.length === 6, "image: leading gap and box-image spacer dropped, blank tail kept");
886
899
  assert(kept[0] === "╭─╮", "image: leading Spacer removed");
887
- assert(kept.indexOf(kittySeq) === 4 && kept.length - 5 === 2, "image: rows after the sequence intact");
900
+ assert(kept.indexOf(kittySeq) === 3 && kept.length - 4 === 2, "image: rows after the sequence intact");
888
901
  assert(
889
902
  stripEdgesKeepingImages(["", "a", "", "b", " ", ""]).join() === "a,,b",
890
903
  "no image: falls back to plain edge stripping",
@@ -1369,5 +1382,11 @@ if (isMain(import.meta.url)) {
1369
1382
  assert(cdpExpanded.some((l) => l.includes('"pageId": "p1"')), "chrome-devtools: expanded shows the full tool output");
1370
1383
  assert(cdpCollapsed.some((l) => l.includes("navigated") && l.includes("to expand")), "chrome-devtools: collapsed shows the first line + hint");
1371
1384
  assert([...cdpCollapsed, ...cdpExpanded].every((l) => visibleWidth(l) === 74), "chrome-devtools: box fills the given width");
1385
+ // Capped timings map self-check
1386
+ for (let i = 0; i < 250; i++) recordTiming(`call_${i}`, i);
1387
+ assert(TIMINGS.size === MAX_TIMINGS, `TIMINGS size is bounded to ${MAX_TIMINGS}`);
1388
+ assert(!TIMINGS.has("call_0") && TIMINGS.has("call_249"), "TIMINGS evicts oldest entries");
1389
+ TIMINGS.clear();
1390
+
1372
1391
  console.log("OK");
1373
1392
  }
@@ -39,7 +39,7 @@ import {
39
39
  } from "@earendil-works/pi-tui";
40
40
  import { readdirSync, readFileSync } from "node:fs";
41
41
  import { renderBoxLines } from "../lib/box.ts";
42
- import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
42
+ import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, softOf } from "../lib/ansi.ts";
43
43
  import { assert, isMain } from "../lib/check.ts";
44
44
  import { contrast, parseOklch, toHex } from "../lib/color.ts";
45
45
  import { expandKeyName } from "../lib/format.ts";
@@ -57,8 +57,9 @@ const RELOAD_TEXT = "Reloading keybindings, extensions, skills, prompts, themes,
57
57
  * whole chat the moment a transcript line mentioned the phrase.
58
58
  */
59
59
  export function shouldHideReloadBox(lines: readonly string[]): boolean {
60
+ if (!lines.some((l) => l.includes("Reloading keybindings"))) return false;
60
61
  const rows = lines.map((l) => stripAnsi(l).trim());
61
- return rows.some((r) => r.includes("Reloading keybindings")) && rows.every((r) => r === "" || /^[─-]+$/.test(r) || RELOAD_TEXT.includes(r));
62
+ return rows.every((r) => r === "" || /^[─-]+$/.test(r) || RELOAD_TEXT.includes(r));
62
63
  }
63
64
  const RELOAD_BOX_KEY = Symbol.for("pi-arnative.reloadBoxHidden");
64
65
  if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[RELOAD_BOX_KEY]) {
@@ -173,7 +174,7 @@ const SELECT_ROW_KEY = Symbol.for("pi-arnative.selectRowColors");
173
174
 
174
175
  /** Selected row body: `·` separators accent, the text around them soft. */
175
176
  export function selectRowText(row: string, th: { fg(c: string, t: string): string }): string {
176
- const soft = accentSoftOf(th);
177
+ const soft = softOf(th);
177
178
  return row
178
179
  .split("·")
179
180
  .map((part) => soft(part))
@@ -397,7 +398,11 @@ if (isMain(import.meta.url)) {
397
398
  const hex = (tok: string) => resolve(themeJson.colors[tok] ?? tok);
398
399
  // The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
399
400
  // so the fg map must be complete; take the whole palette like pi's loader does.
400
- const fgPalette = Object.fromEntries(Object.entries(themeJson.colors).map(([k, v]) => [k, resolve(v)]));
401
+ const fgPalette = Object.fromEntries(
402
+ Object.entries(themeJson.colors)
403
+ .filter(([k]) => k !== "accentSoft")
404
+ .map(([k, v]) => [k, resolve(v)]),
405
+ );
401
406
  const thPill = new Theme(fgPalette, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
402
407
  const pillOriginal = thPill.bg("selectedBg", thPill.fg("text", " ↓ Jump to latest message · End "));
403
408
  const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
@@ -444,13 +449,38 @@ if (isMain(import.meta.url)) {
444
449
  thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
445
450
  };
446
451
  const isColor = (v: string) => /^#[0-9a-f]{6}$/i.test(v) || parseOklch(v) !== null;
452
+ // The tokens pi's published theme schema allows as keys in `colors`. Pi 1.2.0 validates
453
+ // with `additionalProperties: false`, so ANY other key (arnative's `accentSoft` was the
454
+ // offender) fails the whole theme at startup with "must not have additional properties"
455
+ // and "Theme not found". Arnative's own colors belong in `vars`, referenced from these.
456
+ const SCHEMA_TOKENS = new Set([
457
+ "accent", "border", "borderAccent", "borderMuted", "success", "error", "warning",
458
+ "muted", "dim", "text", "thinkingText", "scrollbarTrack", "scrollbarThumb",
459
+ "selectedBg", "searchMatchBg", "searchMatchText", "userMessageBg", "userMessageText",
460
+ "customMessageBg", "customMessageText", "customMessageLabel", "toolPendingBg",
461
+ "toolSuccessBg", "toolErrorBg", "toolTitle", "toolOutput", "mdHeading", "mdLink",
462
+ "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdQuoteBorder",
463
+ "mdHr", "mdListBullet", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext",
464
+ "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString",
465
+ "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation", "thinkingOff",
466
+ "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
467
+ "thinkingMax", "bashMode",
468
+ ]);
447
469
  for (const f of readdirSync(themesDir).filter((n) => n.endsWith(".json")).sort()) {
448
470
  const d = JSON.parse(readFileSync(new URL(f, themesDir), "utf8")) as {
471
+ $schema?: string;
449
472
  name: string;
450
473
  vars: Record<string, string>;
451
474
  colors: Record<string, string>;
452
475
  };
453
476
  assert(d.name === f.replace(/\.json$/, ""), `theme ${f}: name = file name`);
477
+ // Pi 1.2.0 validates `colors` strictly; an extra key rejects the theme at startup.
478
+ const rogue = Object.keys(d.colors).filter((k) => !SCHEMA_TOKENS.has(k));
479
+ assert(rogue.length === 0, `theme ${f}: colors has non-schema keys [${rogue.join(", ")}] — move them to vars`);
480
+ assert(
481
+ d.$schema === "https://pi.dev/schemas/theme.schema.json",
482
+ `theme ${f}: $schema must be the published pi.dev theme schema (got ${d.$schema})`,
483
+ );
454
484
  const missing = required.filter((k) => !(k in d.colors));
455
485
  assert(missing.length === 0, `theme ${f}: required colors complete (missing ${missing.join(",")})`);
456
486
  // colors values may only be: hex, oklch, 256-index, empty, or a REAL variable name —
@@ -478,6 +508,8 @@ if (isMain(import.meta.url)) {
478
508
  // Accent pill: accent bg + toolPendingBg colored text.
479
509
  assert(contrast(hex("accent"), hex("toolPendingBg")) >= 4.5, `theme ${f}: accent vs toolPendingBg contrast >= 4.5`);
480
510
  assert(isColor(d.vars.accentSoft ?? ""), `theme ${f}: vars.accentSoft is a color`);
511
+ assert(isColor(d.vars.primary ?? ""), `theme ${f}: vars.primary is a color`);
512
+ assert(isColor(d.vars.secondary ?? ""), `theme ${f}: vars.secondary is a color`);
481
513
  const canvas = hex("userMessageBg");
482
514
  for (const tok of Object.keys(d.colors)) {
483
515
  if (nonText.has(tok) || tok === "thinkingMax") continue;