@bismawy/pi-arnative 0.2.3 → 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/extensions/footer.ts +48 -67
- package/extensions/section-headers.ts +87 -112
- 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,15 +1,13 @@
|
|
|
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
|
-
* ("╭─ Menu ───╮").
|
|
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 ───╮").
|
|
8
7
|
* - Interactive tab menu (mouse click): Model + Context/Skills/Extensions/Themes/Shortcut
|
|
9
|
-
* with item counts.
|
|
10
|
-
*
|
|
11
|
-
* - Box divider '├────┤'.
|
|
12
|
-
* - Active tab content (left-aligned, neatly wrapped, tint, never stacked).
|
|
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.
|
|
13
11
|
* - Hides pi's built-in stacked list in loadedResourcesContainer.
|
|
14
12
|
*/
|
|
15
13
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -33,6 +31,9 @@ import {
|
|
|
33
31
|
type TuiMouseEventResult,
|
|
34
32
|
} from "@earendil-works/pi-tui";
|
|
35
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";
|
|
36
37
|
import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
|
|
37
38
|
|
|
38
39
|
export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes" | "Shortcut";
|
|
@@ -50,9 +51,6 @@ export const SECTION_ICONS: Record<TabKey, string> = {
|
|
|
50
51
|
Shortcut: "\uf11c", // nf-fa-keyboard_o
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
54
|
-
export const stripAnsi = (s: string) => s.replace(ANSI_RE, "");
|
|
55
|
-
|
|
56
54
|
export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
|
|
57
55
|
|
|
58
56
|
let activeThemeProxy: Themeish = null;
|
|
@@ -76,7 +74,7 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
|
|
|
76
74
|
|
|
77
75
|
export function sectionNameOf(text: string): "Context" | "Skills" | "Extensions" | "Themes" | null {
|
|
78
76
|
const nl = text.indexOf("\n");
|
|
79
|
-
const first = (nl === -1 ? text : text.slice(0, nl)).
|
|
77
|
+
const first = stripAnsi(nl === -1 ? text : text.slice(0, nl)).trim();
|
|
80
78
|
const m = /^\[([A-Za-z]+)\]$/.exec(first);
|
|
81
79
|
const name = m ? m[1] : null;
|
|
82
80
|
if (name === "Context" || name === "Skills" || name === "Extensions" || name === "Themes") {
|
|
@@ -288,26 +286,10 @@ export const tabStore: Map<TabKey, string[]> =
|
|
|
288
286
|
|
|
289
287
|
let activeHeaderInstance: ArnativeHeader | null = null;
|
|
290
288
|
|
|
291
|
-
function capitalize(s: string): string {
|
|
292
|
-
return s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
|
|
293
|
-
}
|
|
294
|
-
|
|
295
289
|
// Model name formatted exactly like in the footer: Gemini 3.8 Flash (Antigravity), Deepseek V4.1 Flash (Yarz), etc.
|
|
296
290
|
export function formatModelDisplayName(model?: { id?: string; name?: string; provider?: string }): string {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const provider = model.provider ? model.provider.charAt(0).toUpperCase() + model.provider.slice(1) : "";
|
|
300
|
-
if (name === model.id) {
|
|
301
|
-
name = name
|
|
302
|
-
.split(/[-_]/)
|
|
303
|
-
.map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
|
|
304
|
-
.join(" ");
|
|
305
|
-
}
|
|
306
|
-
if (provider && name.toLowerCase().endsWith(`(${provider.toLowerCase()})`)) {
|
|
307
|
-
name = name.slice(0, name.lastIndexOf("(")).trim();
|
|
308
|
-
}
|
|
309
|
-
const provStr = provider ? ` (${provider})` : "";
|
|
310
|
-
return `${name}${provStr}`;
|
|
291
|
+
const { name, provider } = modelDisplayParts(model);
|
|
292
|
+
return provider ? `${name} (${provider})` : name;
|
|
311
293
|
}
|
|
312
294
|
|
|
313
295
|
// Full working directory for the Directory tab. ctx.cwd is authoritative; the
|
|
@@ -567,7 +549,7 @@ export class ArnativeHeader implements Component {
|
|
|
567
549
|
infoLines.push(fgFirst(th, ["tint", "text"], line));
|
|
568
550
|
}
|
|
569
551
|
} else if (activeItems.length === 0) {
|
|
570
|
-
infoLines.push(fgFirst(th, ["tint"], "(
|
|
552
|
+
infoLines.push(fgFirst(th, ["tint"], "(empty)"));
|
|
571
553
|
} else if (this.activeTab === "Model") {
|
|
572
554
|
// The Model tab already carries tint styling + a dim separator
|
|
573
555
|
infoLines.push(...activeItems);
|
|
@@ -664,8 +646,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
664
646
|
});
|
|
665
647
|
|
|
666
648
|
pi.on("session_start", async (_event, ctx) => {
|
|
667
|
-
activeThemeProxy = (ctx
|
|
668
|
-
//
|
|
649
|
+
activeThemeProxy = themeOf<Themeish>(ctx) ?? activeThemeProxy;
|
|
650
|
+
// Register the custom header
|
|
669
651
|
ctx.ui?.setHeader?.((tui, theme) => {
|
|
670
652
|
return new ArnativeHeader(tui, theme, ctx);
|
|
671
653
|
});
|
|
@@ -686,57 +668,50 @@ export default function (pi: ExtensionAPI) {
|
|
|
686
668
|
}
|
|
687
669
|
|
|
688
670
|
// Self-check: `node extensions/section-headers.ts`
|
|
689
|
-
|
|
690
|
-
if (isMain) {
|
|
691
|
-
const assert = (cond: boolean, msg: string) => {
|
|
692
|
-
if (!cond) {
|
|
693
|
-
console.error(`FAIL: ${msg}`);
|
|
694
|
-
process.exit(1);
|
|
695
|
-
}
|
|
696
|
-
};
|
|
671
|
+
if (isMain(import.meta.url)) {
|
|
697
672
|
|
|
698
|
-
assert(sectionNameOf("\x1b[33m[Skills]\x1b[39m\n a, b") === "Skills", "header [Skills]
|
|
699
|
-
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");
|
|
700
675
|
|
|
701
676
|
// Parsing & wrapping
|
|
702
|
-
assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody
|
|
703
|
-
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");
|
|
704
679
|
|
|
705
680
|
// Directory path wrapping: never truncated, always the full path
|
|
706
681
|
const wp = wrapPath("/run/media/bisma/DATA/Pi/pi-arnative", 20);
|
|
707
|
-
assert(wp.join("") === "/run/media/bisma/DATA/Pi/pi-arnative", "wrapPath
|
|
708
|
-
assert(wp.every((l) => visibleWidth(l) <= 20), "wrapPath
|
|
709
|
-
assert(wrapPath("D:\\Pi\\pi-arnative", 80).join("") === "D:/Pi/pi-arnative", "wrapPath
|
|
710
|
-
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");
|
|
711
686
|
|
|
712
687
|
const wrapTest = wrapCommaItems(["item1", "item2", "item3", "item4"], 15);
|
|
713
|
-
assert(wrapTest.length >= 2, "wrapCommaItems
|
|
714
|
-
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");
|
|
715
690
|
|
|
716
691
|
const cLine = centerLine("test", 20);
|
|
717
|
-
assert(visibleWidth(cLine) === 20, "centerLine
|
|
718
|
-
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 │");
|
|
719
694
|
|
|
720
695
|
// Model name formatting
|
|
721
696
|
assert(
|
|
722
697
|
formatModelDisplayName({ name: "Gemini 3.8 Flash", provider: "antigravity" }) ===
|
|
723
698
|
"Gemini 3.8 Flash (Antigravity)",
|
|
724
|
-
"formatModelDisplayName
|
|
699
|
+
"formatModelDisplayName with name and provider",
|
|
725
700
|
);
|
|
726
701
|
assert(
|
|
727
702
|
formatModelDisplayName({ id: "deepseek-v4.1-flash", provider: "yarz" }) ===
|
|
728
703
|
"Deepseek V4.1 Flash (Yarz)",
|
|
729
|
-
"formatModelDisplayName
|
|
704
|
+
"formatModelDisplayName converts id to titlecase",
|
|
730
705
|
);
|
|
731
706
|
|
|
732
707
|
// Package & version resolver
|
|
733
|
-
assert(packageNameOf("@bismawy/pi-agentrouter") === "@bismawy/pi-agentrouter", "
|
|
734
|
-
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");
|
|
735
710
|
|
|
736
711
|
const gitTmp = mkdtempSync(join(tmpdir(), "pi-ver-"));
|
|
737
|
-
mkdirSync(join(gitTmp, "git", "github.com", "
|
|
738
|
-
writeFileSync(join(gitTmp, "git", "github.com", "
|
|
739
|
-
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");
|
|
740
715
|
rmSync(gitTmp, { recursive: true, force: true });
|
|
741
716
|
|
|
742
717
|
// ArnativeHeader component & mouse tab click
|
|
@@ -760,77 +735,77 @@ if (isMain) {
|
|
|
760
735
|
// Only "dim" is colored, so the box line colors can be asserted.
|
|
761
736
|
const ansiTheme = { fg: (c: string, t: string) => (c === "dim" ? `\x1b[2m${t}\x1b[22m` : t) };
|
|
762
737
|
const hdr = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
|
|
763
|
-
assert(hdr.activeTab === "Directory", "default tab
|
|
738
|
+
assert(hdr.activeTab === "Directory", "default tab is Directory");
|
|
764
739
|
|
|
765
740
|
// Default view: the working directory path, shown on open
|
|
766
741
|
const linesDefault = hdr.render(100);
|
|
767
|
-
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");
|
|
768
743
|
|
|
769
744
|
hdr.activeTab = "Model";
|
|
770
745
|
const lines100 = hdr.render(100);
|
|
771
|
-
assert(lines100.length === 8, "render 100 = border + 6
|
|
772
|
-
assert(lines100.every((l) => visibleWidth(l) === 100), "
|
|
773
|
-
assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "
|
|
774
|
-
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");
|
|
775
750
|
const logoPlain = buildLogoLines(null);
|
|
776
|
-
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)");
|
|
777
752
|
assert(
|
|
778
753
|
lines100.slice(1, 7).every((l, i) => stripAnsi(l).slice(2, 15) === logoPlain[i]),
|
|
779
|
-
"
|
|
754
|
+
"real Pi ascii in the left column, 6 lines",
|
|
780
755
|
);
|
|
781
756
|
assert(
|
|
782
757
|
/Welcome back, \S/.test(stripAnsi(lines100[1]!)) && !stripAnsi(lines100[1]!).includes("pi v"),
|
|
783
|
-
"
|
|
758
|
+
"line 1: greeting uses the device username",
|
|
784
759
|
);
|
|
785
760
|
assert(
|
|
786
761
|
stripAnsi(lines100[2]!).includes("pi v") && /Arnative v\d+\.\d+\.\d+/.test(stripAnsi(lines100[2]!)),
|
|
787
|
-
"
|
|
762
|
+
"line 2: pi + Arnative + the Arnative build version",
|
|
788
763
|
);
|
|
789
|
-
assert(/╭─ Menu ─+╮/.test(stripAnsi(lines100[3]!)), "
|
|
790
|
-
assert(!stripAnsi(lines100[1]!).includes("Interrupt"), "legend
|
|
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");
|
|
791
766
|
// Both brand names use the theme accent, not only "pi".
|
|
792
767
|
const accentTheme = {
|
|
793
768
|
fg: (c: string, t: string) => (c === "accent" ? `<A>${t}</A>` : c === "dim" ? `<D>${t}</D>` : t),
|
|
794
769
|
};
|
|
795
770
|
const accentHeader = new ArnativeHeader(fakeTui, accentTheme as any, fakeCtx);
|
|
796
771
|
const accentLine = accentHeader.render(100)[2]!;
|
|
797
|
-
assert(accentLine.includes("<A>\x1b[1mpi"), "
|
|
798
|
-
assert(accentLine.includes("<A>\x1b[1mArnative"), "
|
|
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");
|
|
799
774
|
const greetLine = accentHeader.render(100)[1]!;
|
|
800
|
-
assert(greetLine.includes("<D>Welcome back,"), "
|
|
801
|
-
assert(greetLine.includes(`<A>\x1b[1m${deviceUser()}`), "username
|
|
802
|
-
assert(stripAnsi(lines100[4]!).includes("Model [Gemini 3.8 Flash]"), "
|
|
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])");
|
|
803
778
|
// Directory tab: name only in the menu, whole path in the content line below
|
|
804
779
|
hdr.activeTab = "Directory";
|
|
805
780
|
hdr.render(100);
|
|
806
|
-
assert(stripAnsi(lines100[4]!).includes("Directory"), "tab
|
|
807
|
-
assert(!stripAnsi(lines100[4]!).includes("Directory: /"), "menu
|
|
808
|
-
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");
|
|
809
784
|
hdr.activeTab = "Model";
|
|
810
785
|
hdr.render(100);
|
|
811
|
-
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");
|
|
812
787
|
// Directory tab content: full path, wrapped, nothing lost
|
|
813
788
|
hdr.activeTab = "Directory";
|
|
814
789
|
const linesDir = hdr.render(100);
|
|
815
790
|
const dirContent = stripAnsi(linesDir[6]!);
|
|
816
|
-
assert(dirContent.includes("/run/media/bisma"), "
|
|
817
|
-
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");
|
|
818
793
|
hdr.activeTab = "Model";
|
|
819
794
|
hdr.render(100);
|
|
820
795
|
// Inner box rules: bottom line is one fully dimmed block (the "─" runs are colored too, not
|
|
821
796
|
// just the corners); the top one is the same except it carries the tinted " Menu " label.
|
|
822
|
-
assert((lines100[5]!.match(/\x1b\[/g) ?? []).length === 8, '
|
|
797
|
+
assert((lines100[5]!.match(/\x1b\[/g) ?? []).length === 8, 'bottom box line (row 5) = one fully dimmed block');
|
|
823
798
|
assert(
|
|
824
799
|
lines100[3]!.includes("\x1b[2m╭─\x1b[22m Menu \x1b[2m─") && lines100[3]!.includes("╮\x1b[22m"),
|
|
825
|
-
'
|
|
800
|
+
'tab box top border = two dim blocks with the " Menu " label between them',
|
|
826
801
|
);
|
|
827
802
|
|
|
828
803
|
// Tab regions: Model, Context, Skills, Extensions, Themes
|
|
829
804
|
const tabY = (hdr as any).renderedTabLineY;
|
|
830
|
-
assert(tabY > 0, "
|
|
805
|
+
assert(tabY > 0, "tab line Y position recorded");
|
|
831
806
|
const tabRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
|
|
832
|
-
assert(tabRegions.length >= 3, "
|
|
833
|
-
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)");
|
|
834
809
|
|
|
835
810
|
// Click the Extensions tab (when truncated at 100 columns, use the rightmost tab)
|
|
836
811
|
const extRegion = tabRegions.find((r) => r.key === "Extensions") ?? tabRegions[tabRegions.length - 1]!;
|
|
@@ -840,12 +815,12 @@ if (isMain) {
|
|
|
840
815
|
x: Math.floor((extRegion.startX + extRegion.endX) / 2),
|
|
841
816
|
y: tabY,
|
|
842
817
|
} as any);
|
|
843
|
-
assert(clickExt?.handled === true, "
|
|
844
|
-
assert(hdr.activeTab === extRegion.key, "activeTab
|
|
845
|
-
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");
|
|
846
821
|
|
|
847
822
|
const linesExt = hdr.render(100);
|
|
848
|
-
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");
|
|
849
824
|
|
|
850
825
|
// Click the Skills tab (measured at a width where every tab fits)
|
|
851
826
|
const wide = hdr.render(140);
|
|
@@ -860,47 +835,47 @@ if (isMain) {
|
|
|
860
835
|
x: Math.floor((skillsRegion.startX + skillsRegion.endX) / 2),
|
|
861
836
|
y: skillsY,
|
|
862
837
|
} as any);
|
|
863
|
-
assert(hdr.activeTab === skillsRegion.key, `activeTab
|
|
838
|
+
assert(hdr.activeTab === skillsRegion.key, `activeTab switches to ${skillsRegion.key} after the click`);
|
|
864
839
|
|
|
865
840
|
const linesSkills = hdr.render(140);
|
|
866
|
-
assert(wide.every((l) => visibleWidth(l) === 140), "
|
|
867
|
-
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)");
|
|
868
843
|
if (skillsRegion.key === "Skills") {
|
|
869
|
-
assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "
|
|
870
|
-
assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data
|
|
871
|
-
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");
|
|
872
847
|
}
|
|
873
848
|
|
|
874
849
|
// The Y of a tab must point at the tab menu line, not the line above
|
|
875
850
|
const rowsPlain = lines100.map((l) => stripAnsi(l));
|
|
876
|
-
assert(rowsPlain[tabY]!.includes("Model ["), "renderedTabLineY
|
|
877
|
-
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");
|
|
878
853
|
|
|
879
854
|
// Shortcut tab: the key cheatsheet lives in the tab content, not in a header line
|
|
880
855
|
hdr.activeTab = "Shortcut";
|
|
881
856
|
const linesShortcut = hdr.render(170);
|
|
882
857
|
assert(
|
|
883
858
|
((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
|
|
884
|
-
"
|
|
859
|
+
"width 170: Shortcut tab shown (not dropped)",
|
|
885
860
|
);
|
|
886
861
|
const scContent = stripAnsi(linesShortcut.slice(6).join(" "));
|
|
887
|
-
assert(scContent.includes("[Esc] Interrupt"), "tab
|
|
888
|
-
assert(scContent.includes("[Ctrl+alt+r] Reload"), "tab
|
|
889
|
-
assert(stripAnsi(linesShortcut[2]!).includes("Arnative v"), "
|
|
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");
|
|
890
865
|
hdr.activeTab = "Model";
|
|
891
866
|
hdr.render(140);
|
|
892
867
|
|
|
893
868
|
// Narrow screen: brand lines + tabs + data stay intact, rightmost tabs are dropped
|
|
894
869
|
const narrow = hdr.render(60);
|
|
895
|
-
assert(narrow.every((l) => visibleWidth(l) === 60), "
|
|
896
|
-
assert(stripAnsi(narrow[1]!).includes("Welcome back,"), "
|
|
897
|
-
assert(stripAnsi(narrow[2]!).includes("Arnative v"), "
|
|
898
|
-
assert(stripAnsi(narrow[2]!).includes("Interrupt") === false, "legend
|
|
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");
|
|
899
874
|
assert(
|
|
900
875
|
!((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
|
|
901
|
-
"tab
|
|
876
|
+
"rightmost tab dropped at 60 columns",
|
|
902
877
|
);
|
|
903
|
-
assert(stripAnsi(narrow[4]!).includes("Model ["), "tab
|
|
878
|
+
assert(stripAnsi(narrow[4]!).includes("Model ["), "Model tab still shown at 60 columns");
|
|
904
879
|
|
|
905
880
|
// Intercept addChild: capture the data and suppress the child
|
|
906
881
|
const proto = Object.getPrototypeOf(UserMessageComponent.prototype) as { addChild?: unknown };
|
|
@@ -912,8 +887,8 @@ if (isMain) {
|
|
|
912
887
|
render: (_w: number) => ["should be hidden"],
|
|
913
888
|
};
|
|
914
889
|
container.addChild(fakeSection);
|
|
915
|
-
assert(fakeSection.render(80).length === 0, "child
|
|
916
|
-
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");
|
|
917
892
|
|
|
918
893
|
console.log("section-headers.ts self-check OK");
|
|
919
894
|
}
|
package/extensions/timestamps.ts
CHANGED
|
@@ -12,9 +12,38 @@
|
|
|
12
12
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
13
13
|
import { AssistantMessageComponent, UserMessageComponent, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
-
import { ansiBgOpen } from "../lib/ansi.ts";
|
|
15
|
+
import { ansiBgOpen, stripAnsi } from "../lib/ansi.ts";
|
|
16
|
+
import { assert, isMain } from "../lib/check.ts";
|
|
16
17
|
|
|
17
18
|
const USER_TIMESTAMPS_MAP = new Map<string, number>();
|
|
19
|
+
let getBranch: (() => readonly unknown[]) | null = null;
|
|
20
|
+
let prefilled = false;
|
|
21
|
+
|
|
22
|
+
function userTextOf(message: { content?: unknown }): string {
|
|
23
|
+
const c = message.content;
|
|
24
|
+
if (typeof c === "string") return c;
|
|
25
|
+
if (Array.isArray(c)) {
|
|
26
|
+
return c
|
|
27
|
+
.filter((x) => (x as { type?: string })?.type === "text")
|
|
28
|
+
.map((x) => (x as { text?: string }).text ?? "")
|
|
29
|
+
.join("");
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Restore/reload: history entries never fire message_start, so the map is prefilled
|
|
35
|
+
// once from the session branch before the first render lookup. Keyed by trimmed text
|
|
36
|
+
// (last write wins) — ponytail: duplicate texts share one clock, key by message id if
|
|
37
|
+
// pi ever exposes one at render time.
|
|
38
|
+
function prefillUserTimestamps(entries: readonly unknown[]): void {
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const entry = e as { message?: { role?: string; content?: unknown; timestamp?: number }; timestamp?: string };
|
|
41
|
+
const m = entry.message;
|
|
42
|
+
if (m?.role !== "user") continue;
|
|
43
|
+
const text = userTextOf(m).trim();
|
|
44
|
+
if (text) USER_TIMESTAMPS_MAP.set(text, m.timestamp || Date.parse(entry.timestamp ?? "") || Date.now());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
18
47
|
let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
|
|
19
48
|
|
|
20
49
|
function formatClock(timestamp?: number): string {
|
|
@@ -84,15 +113,24 @@ if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
|
|
|
84
113
|
const bgOpen = ansiBgOpen(activeThemeProxy, "userMessageBg");
|
|
85
114
|
for (let i = 0; i < lines.length; i++) lines[i] = repairBubbleBg(lines[i], bgOpen);
|
|
86
115
|
|
|
116
|
+
// Restore/reload safe: old user messages arrive without message_start events,
|
|
117
|
+
// so fill the map once from the session history before the first lookup.
|
|
118
|
+
if (!prefilled && getBranch) {
|
|
119
|
+
prefillUserTimestamps(getBranch());
|
|
120
|
+
prefilled = true;
|
|
121
|
+
}
|
|
122
|
+
|
|
87
123
|
const textKey = (this as { text?: string }).text?.trim() ?? "";
|
|
88
|
-
const ts =
|
|
124
|
+
const ts = USER_TIMESTAMPS_MAP.get(textKey);
|
|
125
|
+
// No known timestamp -> no clock (a "now" clock would be a lie on old messages).
|
|
126
|
+
if (!ts) return lines;
|
|
89
127
|
const timeStr = formatClock(ts);
|
|
90
128
|
const badge = formatTimeBadge(timeStr, activeThemeProxy);
|
|
91
129
|
const timeW = visibleWidth(timeStr);
|
|
92
130
|
|
|
93
131
|
// First text line sits at index 1 (below the box top padding)
|
|
94
132
|
const contentLine = lines[1];
|
|
95
|
-
const cleanContent = contentLine
|
|
133
|
+
const cleanContent = stripAnsi(contentLine).trimEnd();
|
|
96
134
|
const contentW = visibleWidth(cleanContent);
|
|
97
135
|
const minGap = 2;
|
|
98
136
|
|
|
@@ -150,7 +188,7 @@ if (!(globalThis as Record<symbol, boolean>)[CHAT_TIMESTAMP_PATCHED]) {
|
|
|
150
188
|
rest = rest.slice(prefix.length);
|
|
151
189
|
}
|
|
152
190
|
|
|
153
|
-
const cleanText = rest
|
|
191
|
+
const cleanText = stripAnsi(rest).trimEnd();
|
|
154
192
|
const textW = visibleWidth(cleanText);
|
|
155
193
|
const minGap = 2;
|
|
156
194
|
|
|
@@ -167,58 +205,64 @@ export default function (pi: ExtensionAPI) {
|
|
|
167
205
|
// Record the user message timestamp (read by the UserMessageComponent render above)
|
|
168
206
|
pi.on("message_start", async (event) => {
|
|
169
207
|
if (event.message.role !== "user") return;
|
|
170
|
-
const text =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (text.trim()) {
|
|
174
|
-
USER_TIMESTAMPS_MAP.set(text.trim(), event.message.timestamp || Date.now());
|
|
208
|
+
const text = userTextOf(event.message).trim();
|
|
209
|
+
if (text) {
|
|
210
|
+
USER_TIMESTAMPS_MAP.set(text, event.message.timestamp || Date.now());
|
|
175
211
|
}
|
|
176
212
|
});
|
|
177
213
|
|
|
178
214
|
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
179
215
|
pi.on("session_start", async (_event, ctx) => {
|
|
180
|
-
activeThemeProxy =
|
|
216
|
+
activeThemeProxy = themeOf<typeof activeThemeProxy>(ctx) ?? activeThemeProxy;
|
|
217
|
+
// History getter for the restore prefill (render-time, once).
|
|
218
|
+
getBranch = () => ctx.sessionManager.getBranch();
|
|
219
|
+
prefilled = false;
|
|
181
220
|
});
|
|
182
221
|
}
|
|
183
222
|
|
|
184
223
|
// Self-check: `node extensions/timestamps.ts`
|
|
185
|
-
|
|
186
|
-
if (isMain) {
|
|
187
|
-
const assert = (cond: boolean, msg: string) => {
|
|
188
|
-
if (!cond) {
|
|
189
|
-
console.error(`FAIL: ${msg}`);
|
|
190
|
-
process.exit(1);
|
|
191
|
-
}
|
|
192
|
-
};
|
|
224
|
+
if (isMain(import.meta.url)) {
|
|
193
225
|
const badge = "\x1b[36m17:09\x1b[39m";
|
|
194
226
|
const badgeW = 5;
|
|
195
227
|
|
|
196
228
|
// Clock flush right with a 1-space margin
|
|
197
229
|
const res1 = placeTimeAtRight("Short text", 30, badge, badgeW, "");
|
|
198
|
-
assert(visibleWidth(res1) === 30, "
|
|
199
|
-
assert(res1.endsWith(badge + " "), "badge
|
|
230
|
+
assert(visibleWidth(res1) === 30, "line length matches the terminal width");
|
|
231
|
+
assert(res1.endsWith(badge + " "), "badge sits 1 space from the right edge");
|
|
200
232
|
|
|
201
233
|
// With the user bubble background
|
|
202
234
|
const bg = "\x1b[48;2;52;53;61m";
|
|
203
235
|
const res2 = placeTimeAtRight(`${bg}Short text`, 30, badge, badgeW, bg);
|
|
204
|
-
assert(res2.includes(badge + " \x1b[49m"), "
|
|
205
|
-
assert(res2.endsWith("\x1b[49m"), "background
|
|
206
|
-
assert(visibleWidth(res2) === 30, "
|
|
236
|
+
assert(res2.includes(badge + " \x1b[49m"), "hours, margin space, then bg reset");
|
|
237
|
+
assert(res2.endsWith("\x1b[49m"), "background closed at end of line");
|
|
238
|
+
assert(visibleWidth(res2) === 30, "line length still matches the width");
|
|
207
239
|
|
|
208
240
|
// bubble bg repair: padding after [0m] must be bg'd, line closed with [49m]
|
|
209
241
|
const dirty = `${bg}Hello\x1b[0m${" ".repeat(5)}\x1b[49m`;
|
|
210
242
|
const clean = repairBubbleBg(dirty, bg);
|
|
211
|
-
assert(clean.includes(`\x1b[0m${bg}`), "bg
|
|
212
|
-
assert(clean.endsWith("\x1b[49m"), "bg
|
|
213
|
-
assert(repairBubbleBg("plain", "") === "plain", "
|
|
214
|
-
assert(ansiBgOpen(null, "userMessageBg") === "", "
|
|
243
|
+
assert(clean.includes(`\x1b[0m${bg}`), "bg re-applied after the reset");
|
|
244
|
+
assert(clean.endsWith("\x1b[49m"), "bubble bg closed at end of line");
|
|
245
|
+
assert(repairBubbleBg("plain", "") === "plain", "no bgOpen: unchanged");
|
|
246
|
+
assert(ansiBgOpen(null, "userMessageBg") === "", "no theme: empty bgOpen");
|
|
215
247
|
assert(
|
|
216
248
|
ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;52;53;61m${t}\x1b[49m` }, "userMessageBg") === "\x1b[48;2;52;53;61m",
|
|
217
|
-
"bgOpen
|
|
249
|
+
"bgOpen taken from the theme probe",
|
|
218
250
|
);
|
|
219
251
|
|
|
220
|
-
// Long text: sliceByColumn keeps the width intact
|
|
221
|
-
const
|
|
222
|
-
|
|
252
|
+
// Long text: sliceByColumn keeps the width intact (bg branch: badge area carries the bubble bg)
|
|
253
|
+
const bgOpenTest = "\x1b[48;2;52;53;61m";
|
|
254
|
+
const res3 = placeTimeAtRight("a".repeat(40), 30, badge, badgeW, bgOpenTest);
|
|
255
|
+
assert(visibleWidth(res3) === 30, "long text sliced exactly at targetCol");
|
|
256
|
+
assert(res3.includes(bgOpenTest), "bg branch: bubble bg applied in the badge area");
|
|
257
|
+
|
|
258
|
+
// History prefill: old user messages get their real clock on restore
|
|
259
|
+
assert(userTextOf({ content: "plain" }) === "plain", "userTextOf string content");
|
|
260
|
+
assert(userTextOf({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }) === "ab", "userTextOf text parts joined");
|
|
261
|
+
prefillUserTimestamps([
|
|
262
|
+
{ type: "message", timestamp: "2026-01-02T03:04:05.000Z", message: { role: "user", content: "old question", timestamp: 1000 } },
|
|
263
|
+
{ type: "message", timestamp: "not-a-date", message: { role: "user", content: [{ type: "text", text: "old reply" }] } },
|
|
264
|
+
]);
|
|
265
|
+
assert(USER_TIMESTAMPS_MAP.get("old question") === 1000, "prefill stores the message timestamp");
|
|
266
|
+
assert((USER_TIMESTAMPS_MAP.get("old reply") ?? 0) > 0, "prefill falls back to the entry timestamp");
|
|
223
267
|
console.log("timestamps.ts self-check OK");
|
|
224
268
|
}
|