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