@bismawy/pi-arnative 0.4.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1] - 2026-10-13
4
+
5
+ ### Fixed
6
+ - Restored all 11 arnative themes on Pi 1.2.0, which validates the `colors` object with `additionalProperties: false` and rejected the whole theme — "must not have additional properties" followed by "Theme not found" (`lib/ansi.ts`, `themes/gen-themes.mjs`, `themes/*.json`, `extensions/ui-render-tweaks.ts`). Arnative's own colors (`accentSoft`, `primary`, `secondary`, `primarySoft`, `secondarySoft`) moved from `colors` to `vars`.
7
+ - Kept the two-tone soft tier instead of collapsing it to grey: `softOf` replaces `accentSoftOf` and derives the soft accent from the resolved `accent` color when `fg("accentSoft")` throws, so the header logo, tab labels, model line, footer labels, tool titles and the usage dashboard keep their tint (`lib/ansi.ts`).
8
+ - Updated `$schema` in every theme and the generator from the raw GitHub `theme-schema.json` path to the published `https://pi.dev/schemas/theme.schema.json` (`themes/gen-themes.mjs`, `themes/*.json`).
9
+ - Replaced the footer's raw `fg("accentSoft")` probe with the shared painter and mapped `primary`/`secondary` onto the official tokens they carry (`mdHeading`, `borderAccent`), since Pi 1.2 rejects custom `colors` keys (`extensions/footer.ts`).
10
+ - Mapped the usage dashboard's name/value/arrow colors off `accentSoft` onto the shared soft painter and plain `dim`/`text`, keeping provider rows distinguishable (`extensions/usage.ts`).
11
+ - Added a self-check that fails when a theme puts a non-schema key in `colors`, so a rogue token breaks `npm test` instead of the user's startup (`extensions/ui-render-tweaks.ts`).
12
+
13
+ ### Notes
14
+ - `0.5.0` is intentionally skipped on the npm registry. It was tagged and merged, but never published, and its themes are the ones Pi 1.2.0 rejects. `0.5.1` carries everything from v0.5.0 plus this fix. The tag stays in the history; install from npm and you get `0.5.1`.
15
+
16
+ ## [0.5.0] - 2026-10-13
17
+
18
+ ### Added
19
+ - Extended theme token hierarchy in `vars` with `primary`, `primarySoft`, `secondary`, and `secondarySoft` across all 11 themes (`themes/gen-themes.mjs`), ensuring unique hex mappings and full perceptual OKLCH color space discipline.
20
+ - Tri-color multi-palette overhauls for expressive themes (`arnative-cyberpunk`, `arnative-synthwave`, `arnative-dracula`, `arnative-gruvbox`, and `arnative-nord`), mapping `mdHeading` to `primary` and `borderAccent`/`mdCode` to `secondary`.
21
+ - Footer multi-color pairing (`extensions/footer.ts`): paired vibrant `accent` icons with readable `secondarySoft` labels and highlighted the model name with `primary`.
22
+
23
+ ### Changed
24
+ - Increased message bubble background lightness (`surfaceMsg` / `userMessageBg`) on `arnative-cyberpunk`, `arnative-matrix`, and `arnative-synthwave` to 24% (`oklch(24% 0.035 ...)`) to ensure container boundaries remain clearly defined against pitch-black terminal canvases.
25
+
26
+ ### Fixed
27
+ - Fixed parenthetical suffix per-row dimming in `/arnative` → **Animations** so `(system)` on `Pi (system)` renders softly in `dim` even when descriptive frame notes are present (`extensions/arnative.ts`).
28
+ - Fixed inline image preview layout spacing by dropping the redundant spacer gap between the tool execution box and the image payload (`extensions/tools.ts`).
29
+
3
30
  ## [0.4.0] - 2026-10-12
4
31
 
5
32
  ### Added
package/README.md CHANGED
@@ -113,10 +113,18 @@ Theme definitions are generated via `themes/gen-themes.mjs` using OKLCH color ma
113
113
  ```bash
114
114
  npm test # Run unit tests and self-checks
115
115
  npm run themes # Rebuild all theme variants
116
+ npm run preview # Live preview the working tree in your own Pi (stamps a pushable commit)
116
117
  ```
117
118
 
118
119
  Pre-publish verification and tag validation are enforced via `scripts/release-check.mjs`.
119
120
 
121
+ **Local-first push gate:** the global pre-push hook rejects any branch push whose commit
122
+ carries no live-preview stamp, so a fix cannot reach GitHub unseen. `npm run preview`
123
+ launches Pi with this working tree and writes `.git/arnative-preview` for the exact commit
124
+ that was shown; editing `extensions/`, `lib/`, `themes/`, or `package.json` afterwards
125
+ invalidates it and the hook asks for a fresh preview. The preview must run on a clean tree
126
+ — commit or stash first.
127
+
120
128
  </details>
121
129
 
122
130
  ## License
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
9
  import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type Component } from "@earendil-works/pi-tui";
10
- import { accentSoftOf, stripAnsi, thinkingColorOf } from "../lib/ansi.ts";
10
+ import { softOf, stripAnsi, thinkingColorOf } from "../lib/ansi.ts";
11
11
  import { renderScreen, screenTitle, type ScreenText } from "../lib/box.ts";
12
12
  import { assert, isMain } from "../lib/check.ts";
13
13
  import { markedLabels } from "../lib/format.ts";
@@ -172,7 +172,7 @@ export class ListPicker implements Component {
172
172
  render(width: number): string[] {
173
173
  const th = this.theme;
174
174
  const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
175
- const soft = accentSoftOf(th);
175
+ const soft = softOf(th);
176
176
  const edge = (text: string) => this.edge(text);
177
177
  const column = Math.max(0, ...this.rows.map((r) => visibleWidth(r.label)));
178
178
  const body: string[] = [];
@@ -183,7 +183,7 @@ export class ListPicker implements Component {
183
183
  const pad = " ".repeat(Math.max(0, column - visibleWidth(item.label)));
184
184
  const note = item.note ? `${th.fg("dim", ROW_DIVIDER)}${th.fg("dim", item.note)}` : "";
185
185
  // Extract parenthetical suffix to render softly
186
- const suffix = item.note ? "" : /(\(.*\))$/.exec(item.label)?.[1] ?? "";
186
+ const suffix = /(\(.*\))$/.exec(item.label)?.[1] ?? "";
187
187
  const head = suffix ? item.label.slice(0, item.label.length - suffix.length) : item.label;
188
188
  const tail = suffix ? th.fg("dim", suffix) : "";
189
189
  const content = `${isSelected ? th.fg("accent", "→ ") : " "}${paint(head)}${tail}${pad}${note}`;
@@ -673,5 +673,16 @@ if (isMain(import.meta.url)) {
673
673
  assert(footerLines.some((l) => l.includes("Arnative (Minimal)")), "footers picker lists the minimal preset");
674
674
  assert(footerLines.filter((l) => l.includes("Arnative (")).length === 2, "both arnative footer presets are selectable");
675
675
 
676
+ // Animations picker: "Pi (system)" carries a note AND renders "(system)" in dim
677
+ const animPicker = new ListPicker(
678
+ { fg: (c, t) => `<${c}>${t}</${c}>`, bold: (t) => `<bold>${t}</bold>` },
679
+ animationRows(),
680
+ "Pi (system)",
681
+ { preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} },
682
+ ANIMATIONS_PICKER_TEXT,
683
+ );
684
+ const animLines = animPicker.render(120);
685
+ assert(animLines.some((l) => l.includes("Pi ") && l.includes("<dim>(system)</dim>")), "animations picker dims (system)");
686
+
676
687
  console.log("arnative.ts self-check OK");
677
688
  }
@@ -11,7 +11,7 @@ import { execFile } from "node:child_process";
11
11
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { dirname, join, resolve } from "node:path";
14
- import { stripAnsi } from "../lib/ansi.ts";
14
+ import { softOf, stripAnsi } from "../lib/ansi.ts";
15
15
  import { assert, isMain } from "../lib/check.ts";
16
16
  import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
17
17
  import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD, SHORTCUT_TOGGLE_FOOTER } from "../lib/shortcuts.ts";
@@ -398,6 +398,7 @@ function formatModelName(
398
398
  thinkingLevel: string | undefined,
399
399
  acc: (t: string) => string,
400
400
  soft: (t: string) => string,
401
+ primary: (t: string) => string,
401
402
  dim: (t: string) => string,
402
403
  ): string {
403
404
  if (!model) return dim("No Model");
@@ -405,7 +406,7 @@ function formatModelName(
405
406
  const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
406
407
  const capThinking = thinkingLevel && thinkingLevel !== "off" ? capitalize(thinkingLevel) : "";
407
408
  const thinkStr = capThinking ? `${acc("\uee9c")} ${soft(capThinking)} ${dim("·")} ` : "";
408
- return `${thinkStr}${acc(name)}${provStr}`;
409
+ return `${thinkStr}${primary(name)}${provStr}`;
409
410
  }
410
411
 
411
412
  // Footer is a single line: a deep cwd (`/Users/x/Workspace/personal/proj`)
@@ -888,14 +889,9 @@ export default function (pi: ExtensionAPI) {
888
889
  const acc = (text: string) => theme.fg("accent", text);
889
890
  const dim = (text: string) => theme.fg("dim", text);
890
891
  const fgAny = theme.fg.bind(theme) as (color: string, text: string) => string;
891
- let softName = "accent";
892
- try {
893
- fgAny("accentSoft", "");
894
- softName = "accentSoft";
895
- } catch {
896
- // fallback
897
- }
898
- const soft = (text: string) => fgAny(softName, text);
892
+ // `accentSoft` is a vars-only token on Pi 1.2+, so the soft tier is resolved
893
+ // through the shared painter instead of a raw fg() probe.
894
+ const soft = softOf(theme as never);
899
895
  // Diff colors: the same theme slots pi uses for a diff body, with the
900
896
  // plain soft color as fallback for a theme that omits them.
901
897
  const colorOr = (name: string, fallback: (t: string) => string) => (t: string) => {
@@ -905,6 +901,12 @@ export default function (pi: ExtensionAPI) {
905
901
  return fallback(t);
906
902
  }
907
903
  };
904
+ // `primary`/`secondary` are vars, not tokens (Pi 1.2+ rejects custom `colors`
905
+ // keys). Their arnative carriers are the official tokens they map to.
906
+ const primary = colorOr("mdHeading", acc);
907
+ const primarySoft = soft;
908
+ const secondary = colorOr("borderAccent", soft);
909
+ const secondarySoft = soft;
908
910
  const diffAdd = colorOr("toolDiffAdded", soft);
909
911
  const diffDel = colorOr("toolDiffRemoved", soft);
910
912
  const sep = dim(" | ");
@@ -914,7 +916,7 @@ export default function (pi: ExtensionAPI) {
914
916
  const paused = clock.idle();
915
917
  idlePauseShown = paused;
916
918
  const clockIcon = paused ? dim("\uf017") : acc("\uf017");
917
- const clockText = paused ? dim(durationStr) : soft(durationStr);
919
+ const clockText = paused ? dim(durationStr) : secondarySoft(durationStr);
918
920
  const pDuration = `${clockIcon} ${clockText}${diffStat ? `${sep}${diffStat}` : ""}`;
919
921
  const cwdShort = shortenCwd(cwd);
920
922
  const isMinimal = activeFooterPreset() === "Arnative (Minimal)";
@@ -922,10 +924,10 @@ export default function (pi: ExtensionAPI) {
922
924
  const arrow = (expanded: boolean) => dim(expanded ? " ▼" : " ▲");
923
925
  let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
924
926
  if (git) {
925
- left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, soft).map((p) => `${sep}${p}`).join("")}`;
927
+ left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, secondarySoft).map((p) => `${sep}${p}`).join("")}`;
926
928
  }
927
929
  if (isMinimal) left1 += arrow(open.left);
928
- const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim) + (isMinimal ? arrow(open.right) : "");
930
+ const right1 = formatModelName(currentModel, currentThinkingLevel, acc, secondarySoft, primary, dim) + (isMinimal ? arrow(open.right) : "");
929
931
 
930
932
  const statuses: ReadonlyMap<string, string> = (() => {
931
933
  try {
@@ -942,9 +944,9 @@ export default function (pi: ExtensionAPI) {
942
944
  let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
943
945
  rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
944
946
  const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
945
- return `${acc("\uf233")} ${soft(m ? m[0] : rest || "MCP")}`;
947
+ return `${acc("\uf233")} ${secondarySoft(m ? m[0] : rest || "MCP")}`;
946
948
  }
947
- if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, soft);
949
+ if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, secondarySoft);
948
950
  if (s.includes("jev-eye")) {
949
951
  const isOff = s.includes("OFF") || s.includes("○");
950
952
  const isReview = /REVIEW/i.test(s);
@@ -959,11 +961,11 @@ export default function (pi: ExtensionAPI) {
959
961
  const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
960
962
  // Two spaces: \uedcf's ink is wider than MCP's \uf233 or ponytail's \uef04,
961
963
  // so it needs the extra cell to clear the label.
962
- return `${acc("\uedcf")} ${soft("Jev:")} ${bullet} ${soft(label)}`;
964
+ return `${acc("\uedcf")} ${secondarySoft("Jev:")} ${bullet} ${secondarySoft(label)}`;
963
965
  }
964
966
  let clean = stripAnsi(s);
965
967
  clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
966
- return soft(clean.trim());
968
+ return secondarySoft(clean.trim());
967
969
  };
968
970
 
969
971
  const groups: string[] = [];
@@ -976,12 +978,12 @@ export default function (pi: ExtensionAPI) {
976
978
  const opt = parseOptimizer(rawCache);
977
979
  let usageStr = "";
978
980
  try {
979
- usageStr = getUsage(ctx, acc, soft);
981
+ usageStr = getUsage(ctx, acc, secondarySoft);
980
982
  } catch {
981
983
  // fallback
982
984
  }
983
- const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${soft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
984
- const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${soft(opt)}` : "", usageStr].filter(Boolean);
985
+ const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${secondarySoft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
986
+ const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${secondarySoft(opt)}` : "", usageStr].filter(Boolean);
985
987
 
986
988
  // Fixed 3-row grid: head (cwd/model), then statuses and metrics each
987
989
  // wrapped inside their half of the width. Arnative (Minimal) starts collapsed
@@ -1042,11 +1044,7 @@ export default function (pi: ExtensionAPI) {
1042
1044
  try {
1043
1045
  const th = this.getActiveTheme();
1044
1046
  if (!th) return text;
1045
- try {
1046
- return th.fg("accentSoft", text);
1047
- } catch {
1048
- return th.fg("muted", text);
1049
- }
1047
+ return softOf(th as never)(text);
1050
1048
  } catch {
1051
1049
  return text;
1052
1050
  }
@@ -1428,7 +1426,7 @@ if (isMain(import.meta.url)) {
1428
1426
  resetPresetCache();
1429
1427
 
1430
1428
  // Self-check: formatModelName thinking icon
1431
- const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s);
1429
+ const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s, (s) => s);
1432
1430
  assert(fmnRes.includes("\uee9c Low"), "formatModelName includes \\uee9c Low thinking indicator");
1433
1431
 
1434
1432
  // Self-check: the ponytail glyph. \uef51 carries no outline in the Windows Nerd Font
@@ -33,7 +33,7 @@ import {
33
33
  } from "@earendil-works/pi-tui";
34
34
  import type { BoxTheme } from "../lib/box.ts";
35
35
  import { BRAND, renderBoxLines } from "../lib/box.ts";
36
- import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
36
+ import { getActiveTheme, setActiveTheme, softOf, stripAnsi, themeOf } from "../lib/ansi.ts";
37
37
  import { capitalize, expandKeyName, modelDisplayParts } from "../lib/format.ts";
38
38
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
39
39
  import { HEADER_PRESET_KEY as HEADER_PRESET_CONFIG_KEY, loadChoice, saveChoice } from "../lib/preset-store.ts";
@@ -78,6 +78,19 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
78
78
  return text;
79
79
  }
80
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
+
81
94
  /**
82
95
  * Collapsed body of a pi startup section ("[Skills]\n a, b"). pi 0.99 dropped
83
96
  * ExpandableText's own getCollapsedText/getExpandedText fields for a `build` callback
@@ -279,7 +292,7 @@ export function centerLine(text: string, width: number, side = "│"): string {
279
292
  // bar, column 3) in the theme soft — same P/i split as the old wordmark.
280
293
  export function buildLogoLines(th: Themeish): string[] {
281
294
  const a = (s: string) => fgFirst(th, ["accent"], s);
282
- const t = (s: string) => fgFirst(th, ["accentSoft", "text"], s);
295
+ const t = (s: string) => softFirst(th, ["accentSoft", "text"], s);
283
296
  return [
284
297
  `${a("██████")} `,
285
298
  `${a("██ ██")} `,
@@ -483,7 +496,7 @@ export class ArnativeHeader implements Component {
483
496
  const side = fgFirst(th, ["dim"], "│");
484
497
 
485
498
  const infoLines: string[] = [];
486
- 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`);
487
500
  const dim = (text: string) => fgFirst(th, ["dim"], text);
488
501
 
489
502
  // Left column: the mark with the pi version under it (no gap). The column is as
@@ -529,7 +542,7 @@ export class ArnativeHeader implements Component {
529
542
  const isActive = t.key === this.activeTab;
530
543
  const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
531
544
  const labelStyled = isActive
532
- ? fgFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
545
+ ? softFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
533
546
  : fgFirst(th, ["dim"], label);
534
547
  return { key: t.key, plain, formatted: `${iconStyled} ${labelStyled}` };
535
548
  };
@@ -593,19 +606,19 @@ export class ArnativeHeader implements Component {
593
606
  // Model name in soft, its "(Provider)" tail dim.
594
607
  const provSuffix = fullName.match(/(\s*\([^()]*\))$/)?.[1] ?? "";
595
608
  const nameMain = provSuffix ? fullName.slice(0, -provSuffix.length) : fullName;
596
- 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}`)}`;
597
610
 
598
611
  // All-time usage of that model (same as /usage): ↑... ↓... ...
599
612
  const usage = getModelAllTimeUsage(modelObj?.id);
600
613
  const tokenParts: string[] = [];
601
614
  if (usage.input > 0) {
602
- 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))}`);
603
616
  }
604
617
  if (usage.output > 0) {
605
- 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))}`);
606
619
  }
607
620
  if (usage.cacheRead > 0) {
608
- 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))}`);
609
622
  }
610
623
  if (tokenParts.length > 0) {
611
624
  line += `${sep}${tokenParts.join(" ")}`;
@@ -618,11 +631,11 @@ export class ArnativeHeader implements Component {
618
631
 
619
632
  if (this.activeTab === "Directory") {
620
633
  for (const line of wrapPath(getCwd(this.ctx), infoW)) {
621
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
634
+ infoLines.push(softFirst(th, ["accentSoft", "text"], line));
622
635
  }
623
636
  } else if (this.activeTab === "Shortcut") {
624
637
  for (const line of wrapTokens(SHORTCUTS, infoW)) {
625
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
638
+ infoLines.push(softFirst(th, ["accentSoft", "text"], line));
626
639
  }
627
640
  } else if (activeItems.length === 0) {
628
641
  infoLines.push(
@@ -634,7 +647,7 @@ export class ArnativeHeader implements Component {
634
647
  } else {
635
648
  for (const line of wrapCommaItems(activeItems, infoW)) {
636
649
  // Point 5: selected menu data uses soft only
637
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
650
+ infoLines.push(softFirst(th, ["accentSoft", "text"], line));
638
651
  }
639
652
  }
640
653
 
@@ -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
 
@@ -233,15 +233,17 @@ const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; rend
233
233
  // sequence. Not exported by pi-tui, so match both prefixes.
234
234
  export const isImageLine = (line: string): boolean => line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
235
235
 
236
- /** Drop the leading Spacer only when an image preview follows: its blank tail rows
237
- * are the image's vertical space, and deleting them made the terminal draw the
238
- * 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. */
239
239
  export function stripEdgesKeepingImages(lines: string[]): string[] {
240
240
  const firstImage = lines.findIndex(isImageLine);
241
241
  if (firstImage < 0) return stripBlankEdges(lines);
242
242
  let s = 0;
243
243
  while (s < firstImage && isBlankLine(lines[s])) s++;
244
- 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)];
245
247
  }
246
248
 
247
249
  const GAP_KEY = Symbol.for("pi-arnative.toolGapStripped");
@@ -441,7 +443,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
441
443
  ? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
442
444
  : null;
443
445
  return new Lines((width) => {
444
- const soft = accentSoftOf(th);
446
+ const soft = softOf(th);
445
447
  const inner = Math.max(8, width - 4);
446
448
  const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
447
449
  const boxed2 = componentLines(theirs, width - 6);
@@ -604,7 +606,7 @@ function minimal(
604
606
  return new Lines((width) => {
605
607
  // Read context.state live (never capture): renderResult may create it after renderCall.
606
608
  if (context.state?.hasResult) return [];
607
- const soft = accentSoftOf(theme);
609
+ const soft = softOf(theme);
608
610
  const inner = Math.max(8, width - 4);
609
611
  const rows = titleRow(
610
612
  theme,
@@ -633,7 +635,7 @@ function minimal(
633
635
  const dur = fmtMs(context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined);
634
636
  const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
635
637
  return new Lines((width) => {
636
- const soft = accentSoftOf(theme);
638
+ const soft = softOf(theme);
637
639
  const inner = Math.max(8, width - 4);
638
640
  const rows = titleRow(
639
641
  theme,
@@ -893,9 +895,9 @@ if (isMain(import.meta.url)) {
893
895
  const itermSeq = "\x1b]1337;File=inline=1:AAAA\x07";
894
896
  const withImage = ["", "╭─╮", "│ ✓ read form_crop.png │", "╰─╯", "", kittySeq, "", ""];
895
897
  const kept = stripEdgesKeepingImages(withImage);
896
- 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");
897
899
  assert(kept[0] === "╭─╮", "image: leading Spacer removed");
898
- 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");
899
901
  assert(
900
902
  stripEdgesKeepingImages(["", "a", "", "b", " ", ""]).join() === "a,,b",
901
903
  "no image: falls back to plain edge stripping",
@@ -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";
@@ -174,7 +174,7 @@ const SELECT_ROW_KEY = Symbol.for("pi-arnative.selectRowColors");
174
174
 
175
175
  /** Selected row body: `·` separators accent, the text around them soft. */
176
176
  export function selectRowText(row: string, th: { fg(c: string, t: string): string }): string {
177
- const soft = accentSoftOf(th);
177
+ const soft = softOf(th);
178
178
  return row
179
179
  .split("·")
180
180
  .map((part) => soft(part))
@@ -398,7 +398,11 @@ if (isMain(import.meta.url)) {
398
398
  const hex = (tok: string) => resolve(themeJson.colors[tok] ?? tok);
399
399
  // The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
400
400
  // so the fg map must be complete; take the whole palette like pi's loader does.
401
- 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
+ );
402
406
  const thPill = new Theme(fgPalette, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
403
407
  const pillOriginal = thPill.bg("selectedBg", thPill.fg("text", " ↓ Jump to latest message · End "));
404
408
  const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
@@ -445,13 +449,38 @@ if (isMain(import.meta.url)) {
445
449
  thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
446
450
  };
447
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
+ ]);
448
469
  for (const f of readdirSync(themesDir).filter((n) => n.endsWith(".json")).sort()) {
449
470
  const d = JSON.parse(readFileSync(new URL(f, themesDir), "utf8")) as {
471
+ $schema?: string;
450
472
  name: string;
451
473
  vars: Record<string, string>;
452
474
  colors: Record<string, string>;
453
475
  };
454
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
+ );
455
484
  const missing = required.filter((k) => !(k in d.colors));
456
485
  assert(missing.length === 0, `theme ${f}: required colors complete (missing ${missing.join(",")})`);
457
486
  // colors values may only be: hex, oklch, 256-index, empty, or a REAL variable name —
@@ -479,6 +508,8 @@ if (isMain(import.meta.url)) {
479
508
  // Accent pill: accent bg + toolPendingBg colored text.
480
509
  assert(contrast(hex("accent"), hex("toolPendingBg")) >= 4.5, `theme ${f}: accent vs toolPendingBg contrast >= 4.5`);
481
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`);
482
513
  const canvas = hex("userMessageBg");
483
514
  for (const tok of Object.keys(d.colors)) {
484
515
  if (nonText.has(tok) || tok === "thinkingMax") continue;
@@ -11,7 +11,7 @@ import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI, ty
11
11
  import { renderScreen, screenTitle } from "../lib/box.ts";
12
12
  import { assert, isMain } from "../lib/check.ts";
13
13
  import { capitalize } from "../lib/format.ts";
14
- import { thinkingColorOf } from "../lib/ansi.ts";
14
+ import { thinkingColorOf, softOf } from "../lib/ansi.ts";
15
15
  import { legendUsage, matchesBack } from "../lib/shortcuts.ts";
16
16
  import { collectUsageSummary, estimateCost, formatTokens, getModelAllTimeUsage, hasPrice, loadPriceTable, type UsageSummary } from "../lib/usage-store.ts";
17
17
 
@@ -196,7 +196,11 @@ export class UsageModalComponent implements Component {
196
196
  // dashboard reads as belonging to the model that produced the usage.
197
197
  const edge = (t: string) => this.edgeOf(t);
198
198
  const boxW = width;
199
- const soft = (s: string) => th.fg("accentSoft", s);
199
+ // `accentSoft` is a vars-only token on Pi 1.2+; softOf resolves the soft accent either way.
200
+ const soft = softOf(th);
201
+ const acc = (s: string) => th.fg("accent", s);
202
+ const dim = (s: string) => th.fg("dim", s);
203
+ const plain = (s: string) => th.fg("text", s);
200
204
 
201
205
  const rows = this.buildFlatRows();
202
206
  if (this.selectedIndex >= rows.length) {
@@ -255,21 +259,21 @@ export class UsageModalComponent implements Component {
255
259
  const actualIdx = startIdx + i;
256
260
  const isSelected = actualIdx === this.selectedIndex;
257
261
 
258
- const nameColor = isSelected ? "accent" : item.type === "provider" ? "accentSoft" : "text";
259
- const valColor = isSelected ? "accent" : "dim";
262
+ const paintName = isSelected ? acc : item.type === "provider" ? soft : plain;
263
+ const paintVal = isSelected ? acc : dim;
260
264
 
261
- const cName = formatCol(th.fg(nameColor, isSelected ? bold(item.label) : item.label), rowNameW, false);
262
- const cSess = formatCol(th.fg(valColor, formatCount(item.sessions)), sessionW);
263
- const cMsgs = formatCol(th.fg(valColor, formatCount(item.msgs)), messagesW);
264
- const cToks = formatCol(th.fg(valColor, formatTokens(item.tokens)), colW.tokens);
265
+ const cName = formatCol(paintName(isSelected ? bold(item.label) : item.label), rowNameW, false);
266
+ const cSess = formatCol(paintVal(formatCount(item.sessions)), sessionW);
267
+ const cMsgs = formatCol(paintVal(formatCount(item.msgs)), messagesW);
268
+ const cToks = formatCol(paintVal(formatTokens(item.tokens)), colW.tokens);
265
269
  // A row with no reported cost but a rate shows `~$x` in place of `-`, so the guess is
266
270
  // visible exactly where it is one; the report line still keeps it out of the headline.
267
271
  const costCell = item.cost > 0 ? formatCost(item.cost) : item.estimatedCost > 0 ? `~${formatCost(item.estimatedCost)}` : "-";
268
- const cCost = formatCol(th.fg(valColor, costCell), colW.cost);
272
+ const cCost = formatCol(paintVal(costCell), colW.cost);
269
273
 
270
274
  // Gutter: expand arrow for provider or blank indent for model.
271
275
  const gutterText =
272
- item.type === "provider" ? th.fg(item.expanded ? "accentSoft" : "dim", item.expanded ? "▾" : "▸") : " ";
276
+ item.type === "provider" ? (item.expanded ? soft("▾") : dim("▸")) : " ";
273
277
  const line = gutterText + " " + cName + cSess + cMsgs + cToks + cCost;
274
278
  this.rowHitboxes.push({ line: CHROME_ROWS + body.length, index: actualIdx });
275
279
  body.push(line);
@@ -277,7 +281,7 @@ export class UsageModalComponent implements Component {
277
281
 
278
282
  // The selected row's detail sits under the table, where the eye lands after scanning it.
279
283
  body.push("");
280
- body.push(this.selectedDetail(rows[this.selectedIndex], (t) => th.fg("accent", t), (t) => th.fg("accentSoft", t)));
284
+ body.push(this.selectedDetail(rows[this.selectedIndex], acc, soft));
281
285
 
282
286
  // Table-width separator above Total band (skill arnative-pi §6 rule 7).
283
287
  const tableW = colW.name + sessionW + messagesW + colW.tokens + colW.cost;
@@ -285,11 +289,11 @@ export class UsageModalComponent implements Component {
285
289
 
286
290
  // Total row
287
291
  const t = this.summary.totals;
288
- const totalName = formatCol(th.fg("accentSoft", bold("Total")), colW.name, false);
289
- const totalSess = formatCol(th.fg("accentSoft", bold(formatCount(t.sessions))), sessionW);
290
- const totalMsgs = formatCol(th.fg("accentSoft", bold(formatCount(t.msgs))), messagesW);
291
- const totalToks = formatCol(th.fg("accentSoft", bold(formatTokens(t.totalTokens))), colW.tokens);
292
- const totalCost = formatCol(th.fg("accentSoft", bold(formatCost(t.cost))), colW.cost);
292
+ const totalName = formatCol(soft(bold("Total")), colW.name, false);
293
+ const totalSess = formatCol(soft(bold(formatCount(t.sessions))), sessionW);
294
+ const totalMsgs = formatCol(soft(bold(formatCount(t.msgs))), messagesW);
295
+ const totalToks = formatCol(soft(bold(formatTokens(t.totalTokens))), colW.tokens);
296
+ const totalCost = formatCol(soft(bold(formatCost(t.cost))), colW.cost);
293
297
 
294
298
  // Same left edge as the header: one table, three bands, one column grid.
295
299
  body.push(totalName + totalSess + totalMsgs + totalToks + totalCost);