@bismawy/pi-arnative 0.2.2 → 0.2.4
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 +81 -0
- package/README.md +18 -2
- package/extensions/footer.ts +48 -67
- package/extensions/section-headers.ts +196 -129
- package/extensions/timestamps.ts +75 -31
- package/extensions/tools.ts +157 -147
- package/extensions/ui-render-tweaks.ts +101 -119
- package/extensions/usage.ts +17 -25
- package/lib/ansi.ts +22 -9
- package/lib/box.ts +8 -13
- package/lib/check.ts +12 -0
- package/lib/format.ts +33 -0
- package/lib/usage-store.ts +3 -1
- package/package.json +2 -1
- package/themes/gen-themes.mjs +38 -38
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Arnative header box & horizontal tab system:
|
|
3
3
|
* - Round-corner box, fixed height 5 lines: "pi" 3x6 logo on the left, a vertical
|
|
4
|
-
* divider, then 3 left-aligned info lines (
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* divider, then 3 left-aligned info lines (greeting/brand, tab menu, tab data).
|
|
5
|
+
* - Greeting = OS account name; brand line = pi + Arnative with versions. The tab
|
|
6
|
+
* box labels itself on its top border ("╭─ Menu ───╮").
|
|
7
|
+
* - Interactive tab menu (mouse click): Model + Context/Skills/Extensions/Themes/Shortcut
|
|
8
|
+
* with item counts. Icon = accent, text = tint. The key cheatsheet lives in the
|
|
9
|
+
* Shortcut tab, not in a header line.
|
|
10
|
+
* - Box divider '├────┤'. Active tab content is left-aligned, wrapped, tint.
|
|
10
11
|
* - Hides pi's built-in stacked list in loadedResourcesContainer.
|
|
11
12
|
*/
|
|
12
13
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
-
import {
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { tmpdir, userInfo } from "node:os";
|
|
14
16
|
import { join } from "node:path";
|
|
15
17
|
import {
|
|
16
18
|
UserMessageComponent,
|
|
@@ -29,12 +31,15 @@ import {
|
|
|
29
31
|
type TuiMouseEventResult,
|
|
30
32
|
} from "@earendil-works/pi-tui";
|
|
31
33
|
import { renderBoxLines } from "../lib/box.ts";
|
|
34
|
+
import { stripAnsi, themeOf } from "../lib/ansi.ts";
|
|
35
|
+
import { assert, isMain } from "../lib/check.ts";
|
|
36
|
+
import { capitalize, modelDisplayParts } from "../lib/format.ts";
|
|
32
37
|
import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
|
|
33
38
|
|
|
34
|
-
export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes";
|
|
39
|
+
export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes" | "Shortcut";
|
|
35
40
|
|
|
36
41
|
// Tab order for Ctrl+Alt+T and for the menu line.
|
|
37
|
-
export const TAB_ORDER: TabKey[] = ["Directory", "Model", "Context", "Skills", "Extensions", "Themes"];
|
|
42
|
+
export const TAB_ORDER: TabKey[] = ["Directory", "Model", "Context", "Skills", "Extensions", "Themes", "Shortcut"];
|
|
38
43
|
|
|
39
44
|
export const SECTION_ICONS: Record<TabKey, string> = {
|
|
40
45
|
Model: "\uf1b2",
|
|
@@ -43,11 +48,9 @@ export const SECTION_ICONS: Record<TabKey, string> = {
|
|
|
43
48
|
Skills: "\uec21",
|
|
44
49
|
Extensions: "\ueea8",
|
|
45
50
|
Themes: "\uee72",
|
|
51
|
+
Shortcut: "\uf11c", // nf-fa-keyboard_o
|
|
46
52
|
};
|
|
47
53
|
|
|
48
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
49
|
-
export const stripAnsi = (s: string) => s.replace(ANSI_RE, "");
|
|
50
|
-
|
|
51
54
|
export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
|
|
52
55
|
|
|
53
56
|
let activeThemeProxy: Themeish = null;
|
|
@@ -71,7 +74,7 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
|
|
|
71
74
|
|
|
72
75
|
export function sectionNameOf(text: string): "Context" | "Skills" | "Extensions" | "Themes" | null {
|
|
73
76
|
const nl = text.indexOf("\n");
|
|
74
|
-
const first = (nl === -1 ? text : text.slice(0, nl)).
|
|
77
|
+
const first = stripAnsi(nl === -1 ? text : text.slice(0, nl)).trim();
|
|
75
78
|
const m = /^\[([A-Za-z]+)\]$/.exec(first);
|
|
76
79
|
const name = m ? m[1] : null;
|
|
77
80
|
if (name === "Context" || name === "Skills" || name === "Extensions" || name === "Themes") {
|
|
@@ -88,6 +91,21 @@ export function packageNameOf(label: string): string | null {
|
|
|
88
91
|
return /^[\w.@/-]+$/.test(base) ? base : null;
|
|
89
92
|
}
|
|
90
93
|
|
|
94
|
+
/**
|
|
95
|
+
* OS account name of whoever is running pi, for the header greeting. Falls back to the
|
|
96
|
+
* usual env vars, then to a neutral word so the line is never empty.
|
|
97
|
+
*/
|
|
98
|
+
export function deviceUser(): string {
|
|
99
|
+
try {
|
|
100
|
+
const u = userInfo().username;
|
|
101
|
+
if (u) return u;
|
|
102
|
+
} catch {
|
|
103
|
+
/* no passwd entry (some containers) — fall through to env */
|
|
104
|
+
}
|
|
105
|
+
const u = process.env.USER ?? process.env.USERNAME ?? "";
|
|
106
|
+
return u || "there";
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
const versionCache = new Map<string, string | null>();
|
|
92
110
|
|
|
93
111
|
export function readVersion(dir: string): string | null {
|
|
@@ -122,6 +140,11 @@ export function installedVersion(
|
|
|
122
140
|
return version;
|
|
123
141
|
}
|
|
124
142
|
|
|
143
|
+
// Arnative build actually running: package.json one level above this extension file.
|
|
144
|
+
// Reading the module's own package beats reading the npm-installed copy, which lags
|
|
145
|
+
// behind whenever the local clone (or a hand-copied extensions/) is what pi loaded.
|
|
146
|
+
const ARNATIVE_VERSION = readVersion(fileURLToPath(new URL("..", import.meta.url)));
|
|
147
|
+
|
|
125
148
|
export function extractItemsFromBody(body: string, isExtensions = false): string[] {
|
|
126
149
|
const plain = stripAnsi(body).trim();
|
|
127
150
|
if (!plain) return [];
|
|
@@ -145,16 +168,33 @@ export function extractItemsFromBody(body: string, isExtensions = false): string
|
|
|
145
168
|
});
|
|
146
169
|
}
|
|
147
170
|
|
|
171
|
+
// Cheatsheet for the Shortcut tab. Mirrors README "Shortcuts" plus the bindings the
|
|
172
|
+
// extensions themselves own (footer.ts). Kept as plain text so the data line can wrap it.
|
|
173
|
+
export const SHORTCUTS: string[] = [
|
|
174
|
+
"[Esc] Interrupt",
|
|
175
|
+
"[Ctrl+c/d] Exit",
|
|
176
|
+
"[/] Commands",
|
|
177
|
+
"[!] Bash",
|
|
178
|
+
"[Ctrl+o] More/expand",
|
|
179
|
+
"[Ctrl+alt+t] Next tab",
|
|
180
|
+
"[Ctrl+alt+n] New",
|
|
181
|
+
"[Ctrl+alt+r] Reload",
|
|
182
|
+
];
|
|
183
|
+
|
|
148
184
|
export function wrapCommaItems(items: string[], maxWidth: number): string[] {
|
|
185
|
+
return wrapTokens(items, maxWidth, ", ", ",");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function wrapTokens(items: string[], maxWidth: number, sep = " ", trailing = ""): string[] {
|
|
149
189
|
const lines: string[] = [];
|
|
150
190
|
let cur = "";
|
|
151
191
|
for (const item of items) {
|
|
152
192
|
if (!cur) {
|
|
153
193
|
cur = item;
|
|
154
|
-
} else if (visibleWidth(cur) +
|
|
155
|
-
cur +=
|
|
194
|
+
} else if (visibleWidth(cur) + visibleWidth(sep) + visibleWidth(item) <= maxWidth) {
|
|
195
|
+
cur += sep + item;
|
|
156
196
|
} else {
|
|
157
|
-
lines.push(cur +
|
|
197
|
+
lines.push(cur + trailing);
|
|
158
198
|
cur = item;
|
|
159
199
|
}
|
|
160
200
|
}
|
|
@@ -246,26 +286,10 @@ export const tabStore: Map<TabKey, string[]> =
|
|
|
246
286
|
|
|
247
287
|
let activeHeaderInstance: ArnativeHeader | null = null;
|
|
248
288
|
|
|
249
|
-
function capitalize(s: string): string {
|
|
250
|
-
return s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
|
|
251
|
-
}
|
|
252
|
-
|
|
253
289
|
// Model name formatted exactly like in the footer: Gemini 3.8 Flash (Antigravity), Deepseek V4.1 Flash (Yarz), etc.
|
|
254
290
|
export function formatModelDisplayName(model?: { id?: string; name?: string; provider?: string }): string {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const provider = model.provider ? model.provider.charAt(0).toUpperCase() + model.provider.slice(1) : "";
|
|
258
|
-
if (name === model.id) {
|
|
259
|
-
name = name
|
|
260
|
-
.split(/[-_]/)
|
|
261
|
-
.map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
|
|
262
|
-
.join(" ");
|
|
263
|
-
}
|
|
264
|
-
if (provider && name.toLowerCase().endsWith(`(${provider.toLowerCase()})`)) {
|
|
265
|
-
name = name.slice(0, name.lastIndexOf("(")).trim();
|
|
266
|
-
}
|
|
267
|
-
const provStr = provider ? ` (${provider})` : "";
|
|
268
|
-
return `${name}${provStr}`;
|
|
291
|
+
const { name, provider } = modelDisplayParts(model);
|
|
292
|
+
return provider ? `${name} (${provider})` : name;
|
|
269
293
|
}
|
|
270
294
|
|
|
271
295
|
// Full working directory for the Directory tab. ctx.cwd is authoritative; the
|
|
@@ -350,6 +374,13 @@ export class ArnativeHeader implements Component {
|
|
|
350
374
|
count: tabStore.get("Themes")?.length ?? 0,
|
|
351
375
|
label: `Themes [${tabStore.get("Themes")?.length ?? 0}]`,
|
|
352
376
|
},
|
|
377
|
+
{
|
|
378
|
+
key: "Shortcut",
|
|
379
|
+
name: "Shortcut",
|
|
380
|
+
icon: SECTION_ICONS.Shortcut,
|
|
381
|
+
count: SHORTCUTS.length,
|
|
382
|
+
label: `Shortcut [${SHORTCUTS.length}]`,
|
|
383
|
+
},
|
|
353
384
|
];
|
|
354
385
|
}
|
|
355
386
|
|
|
@@ -404,28 +435,17 @@ export class ArnativeHeader implements Component {
|
|
|
404
435
|
const infoLines: string[] = [];
|
|
405
436
|
// Inner box: the tab menu line is framed, content is (infoW - 2) columns.
|
|
406
437
|
const innerW = Math.max(1, infoW - 2);
|
|
438
|
+
const brand = (name: string) => fgFirst(th, ["accent", "tint"], `\x1b[1m${name}\x1b[22m`);
|
|
439
|
+
const dim = (text: string) => fgFirst(th, ["dim"], text);
|
|
440
|
+
|
|
441
|
+
// 1. Greeting: device username, accent like the brand names below it.
|
|
442
|
+
infoLines.push(`${dim("Welcome back,")} ${brand(deviceUser())}`);
|
|
407
443
|
|
|
408
|
-
//
|
|
444
|
+
// 2. Brand line: pi · Arnative, both in the theme accent, versions dim.
|
|
409
445
|
const versionStr = VERSION || "0.87.1";
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
`${fgFirst(th, ["tint"], "[Esc]")} ${fgFirst(th, ["dim"], "Interrupt")}`,
|
|
414
|
-
`${fgFirst(th, ["tint"], "[Ctrl+c/d]")} ${fgFirst(th, ["dim"], "Exit")}`,
|
|
415
|
-
`${fgFirst(th, ["tint"], "[/]")} ${fgFirst(th, ["dim"], "Commands")}`,
|
|
416
|
-
`${fgFirst(th, ["tint"], "[!]")} ${fgFirst(th, ["dim"], "Bash")}`,
|
|
417
|
-
`${fgFirst(th, ["tint"], "[Ctrl+o]")} ${fgFirst(th, ["dim"], "More/expand")}`,
|
|
418
|
-
`${fgFirst(th, ["tint"], "[Ctrl+alt+t]")} ${fgFirst(th, ["dim"], "Next tab")}`,
|
|
419
|
-
`${fgFirst(th, ["tint"], "[Ctrl+alt+r]")} ${fgFirst(th, ["dim"], "Reload")}`,
|
|
420
|
-
];
|
|
421
|
-
// Shortcut list is trimmed from the right (longest prefix that still fits) so
|
|
422
|
-
// narrow screens never clip the tabs or the data.
|
|
423
|
-
let scText = "";
|
|
424
|
-
for (let i = 1; i <= shortcutItems.length; i++) {
|
|
425
|
-
const cand = shortcutItems.slice(0, i).join(" ");
|
|
426
|
-
if (visibleWidth(cand) <= infoW) scText = cand;
|
|
427
|
-
}
|
|
428
|
-
infoLines.push(scText);
|
|
446
|
+
infoLines.push(
|
|
447
|
+
`${brand("pi")} ${dim(`v${versionStr}`)}${dim(" · ")}${brand("Arnative")}${ARNATIVE_VERSION ? ` ${dim(`v${ARNATIVE_VERSION}`)}` : ""}`,
|
|
448
|
+
);
|
|
429
449
|
|
|
430
450
|
// 2. Tab menu: icon = accent, text = tint (bold when active)
|
|
431
451
|
const tabs = this.getTabsData(width);
|
|
@@ -470,8 +490,16 @@ export class ArnativeHeader implements Component {
|
|
|
470
490
|
const t = truncateToWidth(text, tabInnerW);
|
|
471
491
|
return `${side}${t}${" ".repeat(tabInnerW - visibleWidth(t))}${side}`;
|
|
472
492
|
};
|
|
473
|
-
const tabRule = (l: string, r: string): string =>
|
|
474
|
-
|
|
493
|
+
const tabRule = (l: string, r: string, label = ""): string => {
|
|
494
|
+
// Label rides on the top border ("╭─ Menu ───╮") so no extra text line is spent; the
|
|
495
|
+
// word is tint so it reads as a label instead of a dimmer stretch of border.
|
|
496
|
+
const lead = label.length + 1;
|
|
497
|
+
if (label && tabInnerW - lead > 1) {
|
|
498
|
+
return `${fgFirst(th, ["dim"], `${l}─`)}${fgFirst(th, ["tint", "text"], label)}${fgFirst(th, ["dim"], `${"─".repeat(tabInnerW - lead)}${r}`)}`;
|
|
499
|
+
}
|
|
500
|
+
return fgFirst(th, ["dim"], `${l}${"─".repeat(tabInnerW)}${r}`);
|
|
501
|
+
};
|
|
502
|
+
infoLines.push(tabRule("╭", "╮", " Menu "));
|
|
475
503
|
// One space from the left border, then " │ " between tabs.
|
|
476
504
|
infoLines.push(tabRow(tabText));
|
|
477
505
|
// Tab line = top border (index 0) + the infoLines above + this line.
|
|
@@ -516,8 +544,12 @@ export class ArnativeHeader implements Component {
|
|
|
516
544
|
for (const line of wrapPath(getCwd(this.ctx), infoW)) {
|
|
517
545
|
infoLines.push(fgFirst(th, ["tint", "text"], line));
|
|
518
546
|
}
|
|
547
|
+
} else if (this.activeTab === "Shortcut") {
|
|
548
|
+
for (const line of wrapTokens(SHORTCUTS, infoW)) {
|
|
549
|
+
infoLines.push(fgFirst(th, ["tint", "text"], line));
|
|
550
|
+
}
|
|
519
551
|
} else if (activeItems.length === 0) {
|
|
520
|
-
infoLines.push(fgFirst(th, ["tint"], "(
|
|
552
|
+
infoLines.push(fgFirst(th, ["tint"], "(empty)"));
|
|
521
553
|
} else if (this.activeTab === "Model") {
|
|
522
554
|
// The Model tab already carries tint styling + a dim separator
|
|
523
555
|
infoLines.push(...activeItems);
|
|
@@ -614,8 +646,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
614
646
|
});
|
|
615
647
|
|
|
616
648
|
pi.on("session_start", async (_event, ctx) => {
|
|
617
|
-
activeThemeProxy = (ctx
|
|
618
|
-
//
|
|
649
|
+
activeThemeProxy = themeOf<Themeish>(ctx) ?? activeThemeProxy;
|
|
650
|
+
// Register the custom header
|
|
619
651
|
ctx.ui?.setHeader?.((tui, theme) => {
|
|
620
652
|
return new ArnativeHeader(tui, theme, ctx);
|
|
621
653
|
});
|
|
@@ -636,57 +668,50 @@ export default function (pi: ExtensionAPI) {
|
|
|
636
668
|
}
|
|
637
669
|
|
|
638
670
|
// Self-check: `node extensions/section-headers.ts`
|
|
639
|
-
|
|
640
|
-
if (isMain) {
|
|
641
|
-
const assert = (cond: boolean, msg: string) => {
|
|
642
|
-
if (!cond) {
|
|
643
|
-
console.error(`FAIL: ${msg}`);
|
|
644
|
-
process.exit(1);
|
|
645
|
-
}
|
|
646
|
-
};
|
|
671
|
+
if (isMain(import.meta.url)) {
|
|
647
672
|
|
|
648
|
-
assert(sectionNameOf("\x1b[33m[Skills]\x1b[39m\n a, b") === "Skills", "header [Skills]
|
|
649
|
-
assert(sectionNameOf("pi v0.87.1") === null, "
|
|
673
|
+
assert(sectionNameOf("\x1b[33m[Skills]\x1b[39m\n a, b") === "Skills", "header [Skills] detected");
|
|
674
|
+
assert(sectionNameOf("pi v0.87.1") === null, "non-section text is not detected");
|
|
650
675
|
|
|
651
676
|
// Parsing & wrapping
|
|
652
|
-
assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody
|
|
653
|
-
assert(extractItemsFromBody(" x\n y\n z").length === 3, "extractItemsFromBody
|
|
677
|
+
assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody splits 3 comma items");
|
|
678
|
+
assert(extractItemsFromBody(" x\n y\n z").length === 3, "extractItemsFromBody returns 3 lines");
|
|
654
679
|
|
|
655
680
|
// Directory path wrapping: never truncated, always the full path
|
|
656
681
|
const wp = wrapPath("/run/media/bisma/DATA/Pi/pi-arnative", 20);
|
|
657
|
-
assert(wp.join("") === "/run/media/bisma/DATA/Pi/pi-arnative", "wrapPath
|
|
658
|
-
assert(wp.every((l) => visibleWidth(l) <= 20), "wrapPath
|
|
659
|
-
assert(wrapPath("D:\\Pi\\pi-arnative", 80).join("") === "D:/Pi/pi-arnative", "wrapPath
|
|
660
|
-
assert(wrapPath("/a/very-long-segment-name", 6).every((l) => visibleWidth(l) <= 6), "
|
|
682
|
+
assert(wp.join("") === "/run/media/bisma/DATA/Pi/pi-arnative", "wrapPath drops no segment");
|
|
683
|
+
assert(wp.every((l) => visibleWidth(l) <= 20), "wrapPath respects the width");
|
|
684
|
+
assert(wrapPath("D:\\Pi\\pi-arnative", 80).join("") === "D:/Pi/pi-arnative", "wrapPath normalizes backslashes");
|
|
685
|
+
assert(wrapPath("/a/very-long-segment-name", 6).every((l) => visibleWidth(l) <= 6), "long segment is sliced");
|
|
661
686
|
|
|
662
687
|
const wrapTest = wrapCommaItems(["item1", "item2", "item3", "item4"], 15);
|
|
663
|
-
assert(wrapTest.length >= 2, "wrapCommaItems
|
|
664
|
-
assert(visibleWidth(wrapTest[0]!) <= 15, "
|
|
688
|
+
assert(wrapTest.length >= 2, "wrapCommaItems wraps into lines");
|
|
689
|
+
assert(visibleWidth(wrapTest[0]!) <= 15, "wrapped line width within the limit");
|
|
665
690
|
|
|
666
691
|
const cLine = centerLine("test", 20);
|
|
667
|
-
assert(visibleWidth(cLine) === 20, "centerLine
|
|
668
|
-
assert(cLine.startsWith("│") && cLine.endsWith("│"), "centerLine
|
|
692
|
+
assert(visibleWidth(cLine) === 20, "centerLine is exactly 20 columns");
|
|
693
|
+
assert(cLine.startsWith("│") && cLine.endsWith("│"), "centerLine starts and ends with │");
|
|
669
694
|
|
|
670
695
|
// Model name formatting
|
|
671
696
|
assert(
|
|
672
697
|
formatModelDisplayName({ name: "Gemini 3.8 Flash", provider: "antigravity" }) ===
|
|
673
698
|
"Gemini 3.8 Flash (Antigravity)",
|
|
674
|
-
"formatModelDisplayName
|
|
699
|
+
"formatModelDisplayName with name and provider",
|
|
675
700
|
);
|
|
676
701
|
assert(
|
|
677
702
|
formatModelDisplayName({ id: "deepseek-v4.1-flash", provider: "yarz" }) ===
|
|
678
703
|
"Deepseek V4.1 Flash (Yarz)",
|
|
679
|
-
"formatModelDisplayName
|
|
704
|
+
"formatModelDisplayName converts id to titlecase",
|
|
680
705
|
);
|
|
681
706
|
|
|
682
707
|
// Package & version resolver
|
|
683
|
-
assert(packageNameOf("@bismawy/pi-agentrouter") === "@bismawy/pi-agentrouter", "
|
|
684
|
-
assert(packageNameOf("footer.ts") === "footer.ts", "label
|
|
708
|
+
assert(packageNameOf("@bismawy/pi-agentrouter") === "@bismawy/pi-agentrouter", "plain npm label");
|
|
709
|
+
assert(packageNameOf("footer.ts") === "footer.ts", "local label");
|
|
685
710
|
|
|
686
711
|
const gitTmp = mkdtempSync(join(tmpdir(), "pi-ver-"));
|
|
687
|
-
mkdirSync(join(gitTmp, "git", "github.com", "
|
|
688
|
-
writeFileSync(join(gitTmp, "git", "github.com", "
|
|
689
|
-
assert(installedVersion("
|
|
712
|
+
mkdirSync(join(gitTmp, "git", "github.com", "test/arnative"), { recursive: true });
|
|
713
|
+
writeFileSync(join(gitTmp, "git", "github.com", "test/arnative", "package.json"), JSON.stringify({ version: "9.9.9" }));
|
|
714
|
+
assert(installedVersion("test/arnative", join(gitTmp, "npm"), join(gitTmp, "git")) === "9.9.9", "version from the git clone");
|
|
690
715
|
rmSync(gitTmp, { recursive: true, force: true });
|
|
691
716
|
|
|
692
717
|
// ArnativeHeader component & mouse tab click
|
|
@@ -710,54 +735,77 @@ if (isMain) {
|
|
|
710
735
|
// Only "dim" is colored, so the box line colors can be asserted.
|
|
711
736
|
const ansiTheme = { fg: (c: string, t: string) => (c === "dim" ? `\x1b[2m${t}\x1b[22m` : t) };
|
|
712
737
|
const hdr = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
|
|
713
|
-
assert(hdr.activeTab === "Directory", "default tab
|
|
738
|
+
assert(hdr.activeTab === "Directory", "default tab is Directory");
|
|
714
739
|
|
|
715
740
|
// Default view: the working directory path, shown on open
|
|
716
741
|
const linesDefault = hdr.render(100);
|
|
717
|
-
assert(stripAnsi(linesDefault[6]!).includes("/run/media/bisma/DATA/Pi/pi-arnative"), "path
|
|
742
|
+
assert(stripAnsi(linesDefault[6]!).includes("/run/media/bisma/DATA/Pi/pi-arnative"), "directory path shown as soon as the first tab opens");
|
|
718
743
|
|
|
719
744
|
hdr.activeTab = "Model";
|
|
720
745
|
const lines100 = hdr.render(100);
|
|
721
|
-
assert(lines100.length === 8, "render 100 = border + 6
|
|
722
|
-
assert(lines100.every((l) => visibleWidth(l) === 100), "
|
|
723
|
-
assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "
|
|
724
|
-
assert(lines100[7]!.includes("╰") && lines100[7]!.includes("╯"), "
|
|
746
|
+
assert(lines100.length === 8, "render 100 = border + 6 logo lines + border (3 info lines fill 3 logo lines)");
|
|
747
|
+
assert(lines100.every((l) => visibleWidth(l) === 100), "every line at width 100 is exactly 100 columns");
|
|
748
|
+
assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "round top border");
|
|
749
|
+
assert(lines100[7]!.includes("╰") && lines100[7]!.includes("╯"), "round bottom border");
|
|
725
750
|
const logoPlain = buildLogoLines(null);
|
|
726
|
-
assert(logoPlain.length === 6 && visibleWidth(logoPlain[0]!) === 13, "
|
|
751
|
+
assert(logoPlain.length === 6 && visibleWidth(logoPlain[0]!) === 13, "real Pi ascii intact (6 lines x 13 columns)");
|
|
727
752
|
assert(
|
|
728
753
|
lines100.slice(1, 7).every((l, i) => stripAnsi(l).slice(2, 15) === logoPlain[i]),
|
|
729
|
-
"
|
|
754
|
+
"real Pi ascii in the left column, 6 lines",
|
|
730
755
|
);
|
|
731
|
-
assert(
|
|
732
|
-
|
|
756
|
+
assert(
|
|
757
|
+
/Welcome back, \S/.test(stripAnsi(lines100[1]!)) && !stripAnsi(lines100[1]!).includes("pi v"),
|
|
758
|
+
"line 1: greeting uses the device username",
|
|
759
|
+
);
|
|
760
|
+
assert(
|
|
761
|
+
stripAnsi(lines100[2]!).includes("pi v") && /Arnative v\d+\.\d+\.\d+/.test(stripAnsi(lines100[2]!)),
|
|
762
|
+
"line 2: pi + Arnative + the Arnative build version",
|
|
763
|
+
);
|
|
764
|
+
assert(/╭─ Menu ─+╮/.test(stripAnsi(lines100[3]!)), "tab box top border carries the Menu label");
|
|
765
|
+
assert(!stripAnsi(lines100[1]!).includes("Interrupt"), "shortcut legend no longer on the top line");
|
|
766
|
+
// Both brand names use the theme accent, not only "pi".
|
|
767
|
+
const accentTheme = {
|
|
768
|
+
fg: (c: string, t: string) => (c === "accent" ? `<A>${t}</A>` : c === "dim" ? `<D>${t}</D>` : t),
|
|
769
|
+
};
|
|
770
|
+
const accentHeader = new ArnativeHeader(fakeTui, accentTheme as any, fakeCtx);
|
|
771
|
+
const accentLine = accentHeader.render(100)[2]!;
|
|
772
|
+
assert(accentLine.includes("<A>\x1b[1mpi"), "the pi name uses the theme accent");
|
|
773
|
+
assert(accentLine.includes("<A>\x1b[1mArnative"), "the Arnative name uses the same theme accent");
|
|
774
|
+
const greetLine = accentHeader.render(100)[1]!;
|
|
775
|
+
assert(greetLine.includes("<D>Welcome back,"), "greeting text uses the dim color");
|
|
776
|
+
assert(greetLine.includes(`<A>\x1b[1m${deviceUser()}`), "device username uses the theme accent");
|
|
777
|
+
assert(stripAnsi(lines100[4]!).includes("Model [Gemini 3.8 Flash]"), "Model tab in the menu (Model [name])");
|
|
733
778
|
// Directory tab: name only in the menu, whole path in the content line below
|
|
734
779
|
hdr.activeTab = "Directory";
|
|
735
780
|
hdr.render(100);
|
|
736
|
-
assert(stripAnsi(lines100[4]!).includes("Directory"), "tab
|
|
737
|
-
assert(!stripAnsi(lines100[4]!).includes("Directory: /"), "menu
|
|
738
|
-
assert(stripAnsi(lines100[4]!).indexOf("Directory") < stripAnsi(lines100[4]!).indexOf("Model ["), "Directory tab
|
|
781
|
+
assert(stripAnsi(lines100[4]!).includes("Directory"), "Directory tab shown in the menu");
|
|
782
|
+
assert(!stripAnsi(lines100[4]!).includes("Directory: /"), "Directory menu entry has no path (detail only on click)");
|
|
783
|
+
assert(stripAnsi(lines100[4]!).indexOf("Directory") < stripAnsi(lines100[4]!).indexOf("Model ["), "Directory tab is leftmost");
|
|
739
784
|
hdr.activeTab = "Model";
|
|
740
785
|
hdr.render(100);
|
|
741
|
-
assert(stripAnsi(lines100[6]!).includes("Gemini 3.8 Flash (Antigravity)"), "
|
|
786
|
+
assert(stripAnsi(lines100[6]!).includes("Gemini 3.8 Flash (Antigravity)"), "active Model tab data on the last info line");
|
|
742
787
|
// Directory tab content: full path, wrapped, nothing lost
|
|
743
788
|
hdr.activeTab = "Directory";
|
|
744
789
|
const linesDir = hdr.render(100);
|
|
745
790
|
const dirContent = stripAnsi(linesDir[6]!);
|
|
746
|
-
assert(dirContent.includes("/run/media/bisma"), "
|
|
747
|
-
assert(dirContent.includes("pi-arnative"), "
|
|
791
|
+
assert(dirContent.includes("/run/media/bisma"), "full Directory tab path (start) shown");
|
|
792
|
+
assert(dirContent.includes("pi-arnative"), "full Directory tab path (end) shown — no truncation");
|
|
748
793
|
hdr.activeTab = "Model";
|
|
749
794
|
hdr.render(100);
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
795
|
+
// Inner box rules: bottom line is one fully dimmed block (the "─" runs are colored too, not
|
|
796
|
+
// just the corners); the top one is the same except it carries the tinted " Menu " label.
|
|
797
|
+
assert((lines100[5]!.match(/\x1b\[/g) ?? []).length === 8, 'bottom box line (row 5) = one fully dimmed block');
|
|
798
|
+
assert(
|
|
799
|
+
lines100[3]!.includes("\x1b[2m╭─\x1b[22m Menu \x1b[2m─") && lines100[3]!.includes("╮\x1b[22m"),
|
|
800
|
+
'tab box top border = two dim blocks with the " Menu " label between them',
|
|
801
|
+
);
|
|
754
802
|
|
|
755
803
|
// Tab regions: Model, Context, Skills, Extensions, Themes
|
|
756
804
|
const tabY = (hdr as any).renderedTabLineY;
|
|
757
|
-
assert(tabY > 0, "
|
|
805
|
+
assert(tabY > 0, "tab line Y position recorded");
|
|
758
806
|
const tabRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
|
|
759
|
-
assert(tabRegions.length >= 3, "
|
|
760
|
-
assert(tabRegions.some((r) => r.key === "Directory"), "tab
|
|
807
|
+
assert(tabRegions.length >= 3, "tab regions filled (right tabs may be cut at 100 columns)");
|
|
808
|
+
assert(tabRegions.some((r) => r.key === "Directory"), "Directory tab present in the menu (width 100)");
|
|
761
809
|
|
|
762
810
|
// Click the Extensions tab (when truncated at 100 columns, use the rightmost tab)
|
|
763
811
|
const extRegion = tabRegions.find((r) => r.key === "Extensions") ?? tabRegions[tabRegions.length - 1]!;
|
|
@@ -767,12 +815,12 @@ if (isMain) {
|
|
|
767
815
|
x: Math.floor((extRegion.startX + extRegion.endX) / 2),
|
|
768
816
|
y: tabY,
|
|
769
817
|
} as any);
|
|
770
|
-
assert(clickExt?.handled === true, "
|
|
771
|
-
assert(hdr.activeTab === extRegion.key, "activeTab
|
|
772
|
-
assert(renderRequested === true, "requestRender
|
|
818
|
+
assert(clickExt?.handled === true, "mouse click on the tab is handled");
|
|
819
|
+
assert(hdr.activeTab === extRegion.key, "activeTab switches to the clicked tab");
|
|
820
|
+
assert(renderRequested === true, "requestRender called when the tab switches");
|
|
773
821
|
|
|
774
822
|
const linesExt = hdr.render(100);
|
|
775
|
-
assert(stripAnsi(linesExt[6]!).includes(extRegion.key === "Extensions" ? "@bismawy/pi-agentrouter@1.6.1" : ""), "
|
|
823
|
+
assert(stripAnsi(linesExt[6]!).includes(extRegion.key === "Extensions" ? "@bismawy/pi-agentrouter@1.6.1" : ""), "active tab data on the last info line");
|
|
776
824
|
|
|
777
825
|
// Click the Skills tab (measured at a width where every tab fits)
|
|
778
826
|
const wide = hdr.render(140);
|
|
@@ -787,28 +835,47 @@ if (isMain) {
|
|
|
787
835
|
x: Math.floor((skillsRegion.startX + skillsRegion.endX) / 2),
|
|
788
836
|
y: skillsY,
|
|
789
837
|
} as any);
|
|
790
|
-
assert(hdr.activeTab === skillsRegion.key, `activeTab
|
|
838
|
+
assert(hdr.activeTab === skillsRegion.key, `activeTab switches to ${skillsRegion.key} after the click`);
|
|
791
839
|
|
|
792
840
|
const linesSkills = hdr.render(140);
|
|
793
|
-
assert(wide.every((l) => visibleWidth(l) === 140), "
|
|
794
|
-
assert(wideRegions.length === 6, "
|
|
841
|
+
assert(wide.every((l) => visibleWidth(l) === 140), "lines at 140 columns stay 140");
|
|
842
|
+
assert(wideRegions.length === 6, "width 140: first 6 tabs shown (rightmost Shortcut dropped too)");
|
|
795
843
|
if (skillsRegion.key === "Skills") {
|
|
796
|
-
assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "
|
|
797
|
-
assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data
|
|
798
|
-
assert(linesSkills[4]!.includes("Model [Gemini 3.8 Flash]"), "menu
|
|
844
|
+
assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "Skills tab data on the last info line");
|
|
845
|
+
assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "Model data not shown in the Skills tab content");
|
|
846
|
+
assert(linesSkills[4]!.includes("Model [Gemini 3.8 Flash]"), "tab menu still shown while another tab is active");
|
|
799
847
|
}
|
|
800
848
|
|
|
801
|
-
// The Y of a tab must point at the tab menu line, not the
|
|
849
|
+
// The Y of a tab must point at the tab menu line, not the line above
|
|
802
850
|
const rowsPlain = lines100.map((l) => stripAnsi(l));
|
|
803
|
-
assert(rowsPlain[tabY]!.includes("Model ["), "renderedTabLineY
|
|
804
|
-
assert(tabRegions.every((r) => rowsPlain[tabY]!.slice(r.startX, r.endX + 1).includes(r.key === "Model" ? "Model [" : r.key)), "
|
|
851
|
+
assert(rowsPlain[tabY]!.includes("Model ["), "renderedTabLineY points at the tab menu line");
|
|
852
|
+
assert(tabRegions.every((r) => rowsPlain[tabY]!.slice(r.startX, r.endX + 1).includes(r.key === "Model" ? "Model [" : r.key)), "tab regions align with the text on that line");
|
|
853
|
+
|
|
854
|
+
// Shortcut tab: the key cheatsheet lives in the tab content, not in a header line
|
|
855
|
+
hdr.activeTab = "Shortcut";
|
|
856
|
+
const linesShortcut = hdr.render(170);
|
|
857
|
+
assert(
|
|
858
|
+
((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
|
|
859
|
+
"width 170: Shortcut tab shown (not dropped)",
|
|
860
|
+
);
|
|
861
|
+
const scContent = stripAnsi(linesShortcut.slice(6).join(" "));
|
|
862
|
+
assert(scContent.includes("[Esc] Interrupt"), "Shortcut tab: legend moved into the content");
|
|
863
|
+
assert(scContent.includes("[Ctrl+alt+r] Reload"), "Shortcut tab: reload shortcut present in the content");
|
|
864
|
+
assert(stripAnsi(linesShortcut[2]!).includes("Arnative v"), "line 2 keeps the Arnative brand while the Shortcut tab is active");
|
|
865
|
+
hdr.activeTab = "Model";
|
|
866
|
+
hdr.render(140);
|
|
805
867
|
|
|
806
|
-
// Narrow screen:
|
|
868
|
+
// Narrow screen: brand lines + tabs + data stay intact, rightmost tabs are dropped
|
|
807
869
|
const narrow = hdr.render(60);
|
|
808
|
-
assert(narrow.every((l) => visibleWidth(l) === 60), "
|
|
809
|
-
assert(stripAnsi(narrow[1]!).includes("
|
|
810
|
-
assert(stripAnsi(narrow[
|
|
811
|
-
assert(stripAnsi(narrow[
|
|
870
|
+
assert(narrow.every((l) => visibleWidth(l) === 60), "lines at 60 columns stay 60");
|
|
871
|
+
assert(stripAnsi(narrow[1]!).includes("Welcome back,"), "greeting stays intact at 60 columns");
|
|
872
|
+
assert(stripAnsi(narrow[2]!).includes("Arnative v"), "pi + Arnative brand intact at 60 columns");
|
|
873
|
+
assert(stripAnsi(narrow[2]!).includes("Interrupt") === false, "legend does not come back to the top line");
|
|
874
|
+
assert(
|
|
875
|
+
!((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
|
|
876
|
+
"rightmost tab dropped at 60 columns",
|
|
877
|
+
);
|
|
878
|
+
assert(stripAnsi(narrow[4]!).includes("Model ["), "Model tab still shown at 60 columns");
|
|
812
879
|
|
|
813
880
|
// Intercept addChild: capture the data and suppress the child
|
|
814
881
|
const proto = Object.getPrototypeOf(UserMessageComponent.prototype) as { addChild?: unknown };
|
|
@@ -820,8 +887,8 @@ if (isMain) {
|
|
|
820
887
|
render: (_w: number) => ["should be hidden"],
|
|
821
888
|
};
|
|
822
889
|
container.addChild(fakeSection);
|
|
823
|
-
assert(fakeSection.render(80).length === 0, "child
|
|
824
|
-
assert(tabStore.get("Skills")?.includes("new-skill-1") === true, "data
|
|
890
|
+
assert(fakeSection.render(80).length === 0, "child of loadedResourcesContainer suppressed (render = [])");
|
|
891
|
+
assert(tabStore.get("Skills")?.includes("new-skill-1") === true, "new data lands in tabStore");
|
|
825
892
|
|
|
826
893
|
console.log("section-headers.ts self-check OK");
|
|
827
894
|
}
|