@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 +27 -0
- package/README.md +8 -0
- package/extensions/arnative.ts +14 -3
- package/extensions/footer.ts +24 -26
- package/extensions/section-headers.ts +24 -11
- package/extensions/tools.ts +12 -10
- package/extensions/ui-render-tweaks.ts +34 -3
- package/extensions/usage.ts +20 -16
- package/lib/ansi.ts +39 -6
- package/package.json +1 -1
- package/themes/arnative-cyberpunk.json +34 -33
- package/themes/arnative-dracula.json +26 -25
- package/themes/arnative-emerald.json +6 -5
- package/themes/arnative-gruvbox.json +26 -25
- package/themes/arnative-matrix.json +22 -21
- package/themes/arnative-nord.json +23 -22
- package/themes/arnative-sun.json +7 -6
- package/themes/arnative-synthwave.json +34 -33
- package/themes/arnative-violet.json +8 -7
- package/themes/arnative-zinc.json +6 -5
- package/themes/arnative.json +7 -6
- package/themes/gen-themes.mjs +121 -52
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
|
package/extensions/arnative.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/extensions/footer.ts
CHANGED
|
@@ -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}${
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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) :
|
|
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,
|
|
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,
|
|
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")} ${
|
|
947
|
+
return `${acc("\uf233")} ${secondarySoft(m ? m[0] : rest || "MCP")}`;
|
|
946
948
|
}
|
|
947
|
-
if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim,
|
|
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")} ${
|
|
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
|
|
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,
|
|
981
|
+
usageStr = getUsage(ctx, acc, secondarySoft);
|
|
980
982
|
} catch {
|
|
981
983
|
// fallback
|
|
982
984
|
}
|
|
983
|
-
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${
|
|
984
|
-
const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${
|
|
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
|
-
|
|
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) =>
|
|
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"
|
|
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
|
-
?
|
|
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 = `${
|
|
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"], "↑")}${
|
|
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"], "↓")}${
|
|
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 ")}${
|
|
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(
|
|
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(
|
|
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(
|
|
650
|
+
infoLines.push(softFirst(th, ["accentSoft", "text"], line));
|
|
638
651
|
}
|
|
639
652
|
}
|
|
640
653
|
|
package/extensions/tools.ts
CHANGED
|
@@ -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,
|
|
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
|
|
237
|
-
* are the image's vertical space
|
|
238
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ===
|
|
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) ===
|
|
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,
|
|
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 =
|
|
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(
|
|
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;
|
package/extensions/usage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
259
|
-
const
|
|
262
|
+
const paintName = isSelected ? acc : item.type === "provider" ? soft : plain;
|
|
263
|
+
const paintVal = isSelected ? acc : dim;
|
|
260
264
|
|
|
261
|
-
const cName = formatCol(
|
|
262
|
-
const cSess = formatCol(
|
|
263
|
-
const cMsgs = formatCol(
|
|
264
|
-
const cToks = formatCol(
|
|
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(
|
|
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" ?
|
|
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],
|
|
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(
|
|
289
|
-
const totalSess = formatCol(
|
|
290
|
-
const totalMsgs = formatCol(
|
|
291
|
-
const totalToks = formatCol(
|
|
292
|
-
const totalCost = formatCol(
|
|
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);
|