@bismawy/pi-arnative 0.4.0 → 0.5.2

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,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.2] - 2026-10-13
4
+
5
+ ### Fixed
6
+ - Restored the footer's two-tone labels. `0.5.1` moved the label colour onto `softOf(accent)`, which softens the *accent* hue, so cwd/branch/clock/status text inherited the accent (pink) instead of the border-accent (blue) tier and the footer collapsed into a single hue (`extensions/footer.ts`).
7
+ - Added `vars.secondarySoft` to all 11 themes, softened from the same value the `borderAccent` token resolves to (L 0.82, chroma × 0.18, hue kept), and `softSecondaryOf` in `lib/ansi.ts` to derive the identical value at runtime — `vars` names are not addressable via `fg()`, and Pi 1.2 rejects custom `colors` keys, so the declared and derived values must be produced by one recipe.
8
+ - Routed the header's model/thinking/token text, tool bodies, list rows and the usage dashboard onto the label tier. Icons, marks and the logo's second tone stay on the accent tier, so each line keeps its two tones (`extensions/section-headers.ts`, `extensions/tools.ts`, `extensions/usage.ts`, `extensions/arnative.ts`, `extensions/ui-render-tweaks.ts`).
9
+
10
+ ### Added
11
+ - `soften()` is now shared by the generator and the runtime, and `extensions/ui-render-tweaks.ts` asserts every declared `*Soft` var equals its derived value — a drifting or rogue soft colour fails `npm test` instead of shipping (`themes/gen-themes.mjs`, `lib/ansi.ts`).
12
+ - Behavioral test pinning the two-tone rule: labels take the border-accent hue, icons keep the accent hue (`test/footer-minimal.test.ts`).
13
+
14
+ ### Notes
15
+ - Label colour is deliberately softer than the pre-0.5.1 `#05dee7`. `SOFT_CHROMA_SCALE` in `lib/ansi.ts` is the single knob: `0.18` gives `#b2c9ca` on cyberpunk, `0.30` gives `#b2dadc`, `0.50` gives `#9adfe3`. Changing it regenerates all 11 themes and the guard forces them to stay in sync.
16
+
17
+ ## [0.5.1] - 2026-10-13
18
+
19
+ ### Fixed
20
+ - 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`.
21
+ - 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`).
22
+ - 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`).
23
+ - 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`).
24
+ - 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`).
25
+ - 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`).
26
+
27
+ ### Notes
28
+ - `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`.
29
+
30
+ ## [0.5.0] - 2026-10-13
31
+
32
+ ### Added
33
+ - 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.
34
+ - 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`.
35
+ - Footer multi-color pairing (`extensions/footer.ts`): paired vibrant `accent` icons with readable `secondarySoft` labels and highlighted the model name with `primary`.
36
+
37
+ ### Changed
38
+ - 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.
39
+
40
+ ### Fixed
41
+ - 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`).
42
+ - Fixed inline image preview layout spacing by dropping the redundant spacer gap between the tool execution box and the image payload (`extensions/tools.ts`).
43
+
3
44
  ## [0.4.0] - 2026-10-12
4
45
 
5
46
  ### 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 { softSecondaryOf, 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 = softSecondaryOf(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, softSecondaryOf, 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,12 @@ 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`/`secondarySoft` are vars-only tokens on Pi 1.2+, so both soft tiers
893
+ // are resolved through the shared painters instead of a raw fg() probe. Icons keep
894
+ // `accent`; the cwd/branch/clock/status text takes the border-accent tier, so the
895
+ // footer stays two-toned instead of the text inheriting the accent hue.
896
+ const soft = softOf(theme as never);
897
+ const softLabel = softSecondaryOf(theme as never);
899
898
  // Diff colors: the same theme slots pi uses for a diff body, with the
900
899
  // plain soft color as fallback for a theme that omits them.
901
900
  const colorOr = (name: string, fallback: (t: string) => string) => (t: string) => {
@@ -905,6 +904,10 @@ export default function (pi: ExtensionAPI) {
905
904
  return fallback(t);
906
905
  }
907
906
  };
907
+ // `primary` is a var, not a token (Pi 1.2+ rejects custom `colors` keys); its
908
+ // arnative carrier is the official token it maps to.
909
+ const primary = colorOr("mdHeading", acc);
910
+ const secondarySoft = softLabel;
908
911
  const diffAdd = colorOr("toolDiffAdded", soft);
909
912
  const diffDel = colorOr("toolDiffRemoved", soft);
910
913
  const sep = dim(" | ");
@@ -914,7 +917,7 @@ export default function (pi: ExtensionAPI) {
914
917
  const paused = clock.idle();
915
918
  idlePauseShown = paused;
916
919
  const clockIcon = paused ? dim("\uf017") : acc("\uf017");
917
- const clockText = paused ? dim(durationStr) : soft(durationStr);
920
+ const clockText = paused ? dim(durationStr) : secondarySoft(durationStr);
918
921
  const pDuration = `${clockIcon} ${clockText}${diffStat ? `${sep}${diffStat}` : ""}`;
919
922
  const cwdShort = shortenCwd(cwd);
920
923
  const isMinimal = activeFooterPreset() === "Arnative (Minimal)";
@@ -922,10 +925,10 @@ export default function (pi: ExtensionAPI) {
922
925
  const arrow = (expanded: boolean) => dim(expanded ? " ▼" : " ▲");
923
926
  let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
924
927
  if (git) {
925
- left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, soft).map((p) => `${sep}${p}`).join("")}`;
928
+ left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, secondarySoft).map((p) => `${sep}${p}`).join("")}`;
926
929
  }
927
930
  if (isMinimal) left1 += arrow(open.left);
928
- const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim) + (isMinimal ? arrow(open.right) : "");
931
+ const right1 = formatModelName(currentModel, currentThinkingLevel, acc, secondarySoft, primary, dim) + (isMinimal ? arrow(open.right) : "");
929
932
 
930
933
  const statuses: ReadonlyMap<string, string> = (() => {
931
934
  try {
@@ -942,9 +945,9 @@ export default function (pi: ExtensionAPI) {
942
945
  let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
943
946
  rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
944
947
  const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
945
- return `${acc("\uf233")} ${soft(m ? m[0] : rest || "MCP")}`;
948
+ return `${acc("\uf233")} ${secondarySoft(m ? m[0] : rest || "MCP")}`;
946
949
  }
947
- if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, soft);
950
+ if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, secondarySoft);
948
951
  if (s.includes("jev-eye")) {
949
952
  const isOff = s.includes("OFF") || s.includes("○");
950
953
  const isReview = /REVIEW/i.test(s);
@@ -959,11 +962,11 @@ export default function (pi: ExtensionAPI) {
959
962
  const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
960
963
  // Two spaces: \uedcf's ink is wider than MCP's \uf233 or ponytail's \uef04,
961
964
  // so it needs the extra cell to clear the label.
962
- return `${acc("\uedcf")} ${soft("Jev:")} ${bullet} ${soft(label)}`;
965
+ return `${acc("\uedcf")} ${secondarySoft("Jev:")} ${bullet} ${secondarySoft(label)}`;
963
966
  }
964
967
  let clean = stripAnsi(s);
965
968
  clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
966
- return soft(clean.trim());
969
+ return secondarySoft(clean.trim());
967
970
  };
968
971
 
969
972
  const groups: string[] = [];
@@ -976,12 +979,12 @@ export default function (pi: ExtensionAPI) {
976
979
  const opt = parseOptimizer(rawCache);
977
980
  let usageStr = "";
978
981
  try {
979
- usageStr = getUsage(ctx, acc, soft);
982
+ usageStr = getUsage(ctx, acc, secondarySoft);
980
983
  } catch {
981
984
  // fallback
982
985
  }
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);
986
+ const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${secondarySoft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
987
+ const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${secondarySoft(opt)}` : "", usageStr].filter(Boolean);
985
988
 
986
989
  // Fixed 3-row grid: head (cwd/model), then statuses and metrics each
987
990
  // wrapped inside their half of the width. Arnative (Minimal) starts collapsed
@@ -1042,11 +1045,7 @@ export default function (pi: ExtensionAPI) {
1042
1045
  try {
1043
1046
  const th = this.getActiveTheme();
1044
1047
  if (!th) return text;
1045
- try {
1046
- return th.fg("accentSoft", text);
1047
- } catch {
1048
- return th.fg("muted", text);
1049
- }
1048
+ return softSecondaryOf(th as never)(text);
1050
1049
  } catch {
1051
1050
  return text;
1052
1051
  }
@@ -1428,7 +1427,7 @@ if (isMain(import.meta.url)) {
1428
1427
  resetPresetCache();
1429
1428
 
1430
1429
  // Self-check: formatModelName thinking icon
1431
- const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s);
1430
+ const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s, (s) => s);
1432
1431
  assert(fmnRes.includes("\uee9c Low"), "formatModelName includes \\uee9c Low thinking indicator");
1433
1432
 
1434
1433
  // 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, softSecondaryOf, 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,34 @@ 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 *accent* tier — the logo mark's second tone, paired with
83
+ * the accent `P`. Keeps the mark in one hue family instead of blue.
84
+ *
85
+ * Pi 1.2.0 rejects a custom `accentSoft` key in `colors`, so `fgFirst(["accentSoft", …])`
86
+ * silently degrades to plain text there and the two-tone look flattens. `softOf` derives
87
+ * the same tint from the resolved accent; a theme that genuinely defines `accentSoft`
88
+ * still wins first.
89
+ */
90
+ export function softFirst(th: Themeish, fallbacks: string[], text: string): string {
91
+ return softOnTier(softOf(th as never), th, fallbacks, text);
92
+ }
93
+
94
+ /**
95
+ * Painter for the soft *label* tier of header text (model name, thinking level, token
96
+ * counts). Same colour language as the footer: marks take `accent`, their text takes the
97
+ * soft border-accent tier, so a header line and the footer under it agree.
98
+ */
99
+ export function softLabelFirst(th: Themeish, fallbacks: string[], text: string): string {
100
+ return softOnTier(softSecondaryOf(th as never), th, fallbacks, text);
101
+ }
102
+
103
+ /** Shared tail of the two soft painters: use the resolved soft tone, else the fallback tokens. */
104
+ function softOnTier(paint: (s: string) => string, th: Themeish, fallbacks: string[], text: string): string {
105
+ const out = paint(text);
106
+ return out !== text ? out : fgFirst(th, fallbacks, text);
107
+ }
108
+
81
109
  /**
82
110
  * Collapsed body of a pi startup section ("[Skills]\n a, b"). pi 0.99 dropped
83
111
  * ExpandableText's own getCollapsedText/getExpandedText fields for a `build` callback
@@ -279,7 +307,7 @@ export function centerLine(text: string, width: number, side = "│"): string {
279
307
  // bar, column 3) in the theme soft — same P/i split as the old wordmark.
280
308
  export function buildLogoLines(th: Themeish): string[] {
281
309
  const a = (s: string) => fgFirst(th, ["accent"], s);
282
- const t = (s: string) => fgFirst(th, ["accentSoft", "text"], s);
310
+ const t = (s: string) => softFirst(th, ["accentSoft", "text"], s);
283
311
  return [
284
312
  `${a("██████")} `,
285
313
  `${a("██ ██")} `,
@@ -483,7 +511,7 @@ export class ArnativeHeader implements Component {
483
511
  const side = fgFirst(th, ["dim"], "│");
484
512
 
485
513
  const infoLines: string[] = [];
486
- const brand = (name: string) => fgFirst(th, ["accent", "accentSoft"], `\x1b[1m${name}\x1b[22m`);
514
+ const brand = (name: string) => fgFirst(th, ["accent"], `\x1b[1m${name}\x1b[22m`);
487
515
  const dim = (text: string) => fgFirst(th, ["dim"], text);
488
516
 
489
517
  // Left column: the mark with the pi version under it (no gap). The column is as
@@ -529,7 +557,7 @@ export class ArnativeHeader implements Component {
529
557
  const isActive = t.key === this.activeTab;
530
558
  const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
531
559
  const labelStyled = isActive
532
- ? fgFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
560
+ ? softLabelFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
533
561
  : fgFirst(th, ["dim"], label);
534
562
  return { key: t.key, plain, formatted: `${iconStyled} ${labelStyled}` };
535
563
  };
@@ -593,19 +621,19 @@ export class ArnativeHeader implements Component {
593
621
  // Model name in soft, its "(Provider)" tail dim.
594
622
  const provSuffix = fullName.match(/(\s*\([^()]*\))$/)?.[1] ?? "";
595
623
  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}`)}`;
624
+ let line = `${softLabelFirst(th, ["accentSoft", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${softLabelFirst(th, ["accentSoft", "text"], `Thinking: ${thinkLevel}`)}`;
597
625
 
598
626
  // All-time usage of that model (same as /usage): ↑... ↓... ...
599
627
  const usage = getModelAllTimeUsage(modelObj?.id);
600
628
  const tokenParts: string[] = [];
601
629
  if (usage.input > 0) {
602
- tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
630
+ tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
603
631
  }
604
632
  if (usage.output > 0) {
605
- tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
633
+ tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
606
634
  }
607
635
  if (usage.cacheRead > 0) {
608
- tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
636
+ tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
609
637
  }
610
638
  if (tokenParts.length > 0) {
611
639
  line += `${sep}${tokenParts.join(" ")}`;
@@ -618,11 +646,11 @@ export class ArnativeHeader implements Component {
618
646
 
619
647
  if (this.activeTab === "Directory") {
620
648
  for (const line of wrapPath(getCwd(this.ctx), infoW)) {
621
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
649
+ infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
622
650
  }
623
651
  } else if (this.activeTab === "Shortcut") {
624
652
  for (const line of wrapTokens(SHORTCUTS, infoW)) {
625
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
653
+ infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
626
654
  }
627
655
  } else if (activeItems.length === 0) {
628
656
  infoLines.push(
@@ -634,7 +662,7 @@ export class ArnativeHeader implements Component {
634
662
  } else {
635
663
  for (const line of wrapCommaItems(activeItems, infoW)) {
636
664
  // Point 5: selected menu data uses soft only
637
- infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
665
+ infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
638
666
  }
639
667
  }
640
668
 
@@ -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, softSecondaryOf } 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 = softSecondaryOf(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 = softSecondaryOf(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 = softSecondaryOf(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,9 +39,9 @@ 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, softSecondaryOf } from "../lib/ansi.ts";
43
43
  import { assert, isMain } from "../lib/check.ts";
44
- import { contrast, parseOklch, toHex } from "../lib/color.ts";
44
+ import { contrast, oklchToRgb, parseOklch, toHex } from "../lib/color.ts";
45
45
  import { expandKeyName } from "../lib/format.ts";
46
46
 
47
47
  type ActiveTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null;
@@ -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 = softSecondaryOf(th);
178
178
  return row
179
179
  .split("·")
180
180
  .map((part) => soft(part))
@@ -396,9 +396,21 @@ if (isMain(import.meta.url)) {
396
396
  // hex, so resolve every token through lib/color before constructing it.
397
397
  const resolve = (value: string) => toHex(themeJson.vars[value] ?? value);
398
398
  const hex = (tok: string) => resolve(themeJson.colors[tok] ?? tok);
399
+ // Mirrors `soften()` (themes/gen-themes.mjs) and `softenColor` (lib/ansi.ts): the soft
400
+ // tier is the base color at L 0.82 with a fifth of its chroma. Both sides read the
401
+ // declared OKLCH string (not the 8-bit hex), so the comparison is not rounded twice.
402
+ const softenToHex = (base: string) => {
403
+ const o = parseOklch(base);
404
+ assert(o !== null, `cannot soften a non-oklch color: ${base}`);
405
+ return toHex(`oklch(82% ${oklchToRgb(0.82, o.C * 0.18, o.H).chroma} ${o.H})`);
406
+ };
399
407
  // The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
400
408
  // 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)]));
409
+ const fgPalette = Object.fromEntries(
410
+ Object.entries(themeJson.colors)
411
+ .filter(([k]) => k !== "accentSoft")
412
+ .map(([k, v]) => [k, resolve(v)]),
413
+ );
402
414
  const thPill = new Theme(fgPalette, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
403
415
  const pillOriginal = thPill.bg("selectedBg", thPill.fg("text", " ↓ Jump to latest message · End "));
404
416
  const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
@@ -445,13 +457,38 @@ if (isMain(import.meta.url)) {
445
457
  thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
446
458
  };
447
459
  const isColor = (v: string) => /^#[0-9a-f]{6}$/i.test(v) || parseOklch(v) !== null;
460
+ // The tokens pi's published theme schema allows as keys in `colors`. Pi 1.2.0 validates
461
+ // with `additionalProperties: false`, so ANY other key (arnative's `accentSoft` was the
462
+ // offender) fails the whole theme at startup with "must not have additional properties"
463
+ // and "Theme not found". Arnative's own colors belong in `vars`, referenced from these.
464
+ const SCHEMA_TOKENS = new Set([
465
+ "accent", "border", "borderAccent", "borderMuted", "success", "error", "warning",
466
+ "muted", "dim", "text", "thinkingText", "scrollbarTrack", "scrollbarThumb",
467
+ "selectedBg", "searchMatchBg", "searchMatchText", "userMessageBg", "userMessageText",
468
+ "customMessageBg", "customMessageText", "customMessageLabel", "toolPendingBg",
469
+ "toolSuccessBg", "toolErrorBg", "toolTitle", "toolOutput", "mdHeading", "mdLink",
470
+ "mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdQuoteBorder",
471
+ "mdHr", "mdListBullet", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext",
472
+ "syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString",
473
+ "syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation", "thinkingOff",
474
+ "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
475
+ "thinkingMax", "bashMode",
476
+ ]);
448
477
  for (const f of readdirSync(themesDir).filter((n) => n.endsWith(".json")).sort()) {
449
478
  const d = JSON.parse(readFileSync(new URL(f, themesDir), "utf8")) as {
479
+ $schema?: string;
450
480
  name: string;
451
481
  vars: Record<string, string>;
452
482
  colors: Record<string, string>;
453
483
  };
454
484
  assert(d.name === f.replace(/\.json$/, ""), `theme ${f}: name = file name`);
485
+ // Pi 1.2.0 validates `colors` strictly; an extra key rejects the theme at startup.
486
+ const rogue = Object.keys(d.colors).filter((k) => !SCHEMA_TOKENS.has(k));
487
+ assert(rogue.length === 0, `theme ${f}: colors has non-schema keys [${rogue.join(", ")}] — move them to vars`);
488
+ assert(
489
+ d.$schema === "https://pi.dev/schemas/theme.schema.json",
490
+ `theme ${f}: $schema must be the published pi.dev theme schema (got ${d.$schema})`,
491
+ );
455
492
  const missing = required.filter((k) => !(k in d.colors));
456
493
  assert(missing.length === 0, `theme ${f}: required colors complete (missing ${missing.join(",")})`);
457
494
  // colors values may only be: hex, oklch, 256-index, empty, or a REAL variable name —
@@ -479,6 +516,18 @@ if (isMain(import.meta.url)) {
479
516
  // Accent pill: accent bg + toolPendingBg colored text.
480
517
  assert(contrast(hex("accent"), hex("toolPendingBg")) >= 4.5, `theme ${f}: accent vs toolPendingBg contrast >= 4.5`);
481
518
  assert(isColor(d.vars.accentSoft ?? ""), `theme ${f}: vars.accentSoft is a color`);
519
+ assert(isColor(d.vars.secondarySoft ?? ""), `theme ${f}: vars.secondarySoft is a color`);
520
+ assert(isColor(d.vars.primary ?? ""), `theme ${f}: vars.primary is a color`);
521
+ assert(isColor(d.vars.secondary ?? ""), `theme ${f}: vars.secondary is a color`);
522
+ // The declared soft vars must equal what the extensions derive at runtime, or the
523
+ // footer label colour drifts from the theme file. `softenToHex` mirrors
524
+ // `soften()` in gen-themes.mjs and `softenColor` in lib/ansi.ts.
525
+ for (const [softName, baseName] of [["accentSoft", "accent"], ["secondarySoft", "borderAccent"]] as const) {
526
+ const base = d.vars[d.colors[baseName] ?? baseName]!;
527
+ const declared = toHex(d.vars[softName]!);
528
+ const derived = softenToHex(base);
529
+ assert(declared === derived, `theme ${f}: vars.${softName} ${declared} != runtime ${derived} (derived from ${baseName})`);
530
+ }
482
531
  const canvas = hex("userMessageBg");
483
532
  for (const tok of Object.keys(d.colors)) {
484
533
  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, softSecondaryOf } 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,12 @@ 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
+ // `secondarySoft` is a vars-only token on Pi 1.2+; softSecondaryOf resolves the soft
200
+ // label tier (the border-accent hue) and falls back to the accent tier.
201
+ const soft = softSecondaryOf(th);
202
+ const acc = (s: string) => th.fg("accent", s);
203
+ const dim = (s: string) => th.fg("dim", s);
204
+ const plain = (s: string) => th.fg("text", s);
200
205
 
201
206
  const rows = this.buildFlatRows();
202
207
  if (this.selectedIndex >= rows.length) {
@@ -255,21 +260,21 @@ export class UsageModalComponent implements Component {
255
260
  const actualIdx = startIdx + i;
256
261
  const isSelected = actualIdx === this.selectedIndex;
257
262
 
258
- const nameColor = isSelected ? "accent" : item.type === "provider" ? "accentSoft" : "text";
259
- const valColor = isSelected ? "accent" : "dim";
263
+ const paintName = isSelected ? acc : item.type === "provider" ? soft : plain;
264
+ const paintVal = isSelected ? acc : dim;
260
265
 
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);
266
+ const cName = formatCol(paintName(isSelected ? bold(item.label) : item.label), rowNameW, false);
267
+ const cSess = formatCol(paintVal(formatCount(item.sessions)), sessionW);
268
+ const cMsgs = formatCol(paintVal(formatCount(item.msgs)), messagesW);
269
+ const cToks = formatCol(paintVal(formatTokens(item.tokens)), colW.tokens);
265
270
  // A row with no reported cost but a rate shows `~$x` in place of `-`, so the guess is
266
271
  // visible exactly where it is one; the report line still keeps it out of the headline.
267
272
  const costCell = item.cost > 0 ? formatCost(item.cost) : item.estimatedCost > 0 ? `~${formatCost(item.estimatedCost)}` : "-";
268
- const cCost = formatCol(th.fg(valColor, costCell), colW.cost);
273
+ const cCost = formatCol(paintVal(costCell), colW.cost);
269
274
 
270
275
  // Gutter: expand arrow for provider or blank indent for model.
271
276
  const gutterText =
272
- item.type === "provider" ? th.fg(item.expanded ? "accentSoft" : "dim", item.expanded ? "▾" : "▸") : " ";
277
+ item.type === "provider" ? (item.expanded ? soft("▾") : dim("▸")) : " ";
273
278
  const line = gutterText + " " + cName + cSess + cMsgs + cToks + cCost;
274
279
  this.rowHitboxes.push({ line: CHROME_ROWS + body.length, index: actualIdx });
275
280
  body.push(line);
@@ -277,7 +282,7 @@ export class UsageModalComponent implements Component {
277
282
 
278
283
  // The selected row's detail sits under the table, where the eye lands after scanning it.
279
284
  body.push("");
280
- body.push(this.selectedDetail(rows[this.selectedIndex], (t) => th.fg("accent", t), (t) => th.fg("accentSoft", t)));
285
+ body.push(this.selectedDetail(rows[this.selectedIndex], acc, soft));
281
286
 
282
287
  // Table-width separator above Total band (skill arnative-pi §6 rule 7).
283
288
  const tableW = colW.name + sessionW + messagesW + colW.tokens + colW.cost;
@@ -285,11 +290,11 @@ export class UsageModalComponent implements Component {
285
290
 
286
291
  // Total row
287
292
  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);
293
+ const totalName = formatCol(soft(bold("Total")), colW.name, false);
294
+ const totalSess = formatCol(soft(bold(formatCount(t.sessions))), sessionW);
295
+ const totalMsgs = formatCol(soft(bold(formatCount(t.msgs))), messagesW);
296
+ const totalToks = formatCol(soft(bold(formatTokens(t.totalTokens))), colW.tokens);
297
+ const totalCost = formatCol(soft(bold(formatCost(t.cost))), colW.cost);
293
298
 
294
299
  // Same left edge as the header: one table, three bands, one column grid.
295
300
  body.push(totalName + totalSess + totalMsgs + totalToks + totalCost);