@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.
@@ -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 (version/shortcuts, tabs, data).
5
- * - Info line 1: "Welcome back, <device username>" (os.userInfo, env fallback). Line 2:
6
- * pi + Arnative brand with versions. The tab box labels itself on its top border
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. Tab icon = accent, tab text = tint. The key cheatsheet lives in
10
- * the Shortcut tab, not in a header line.
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)).replace(ANSI_RE, "").trim();
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
- if (!model) return "No Model";
298
- let name = model.name || model.id || "Model";
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"], "(kosong)"));
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 as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme ?? activeThemeProxy;
668
- // Daftarkan Custom Header
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
- const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
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] terdeteksi");
699
- assert(sectionNameOf("pi v0.87.1") === null, "teks non-seksi tak terdeteksi");
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 koma = 3");
703
- assert(extractItemsFromBody(" x\n y\n z").length === 3, "extractItemsFromBody baris = 3");
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 tak menghilangkan segmen");
708
- assert(wp.every((l) => visibleWidth(l) <= 20), "wrapPath hormati lebar");
709
- assert(wrapPath("D:\\Pi\\pi-arnative", 80).join("") === "D:/Pi/pi-arnative", "wrapPath normalisasi backslash");
710
- assert(wrapPath("/a/very-long-segment-name", 6).every((l) => visibleWidth(l) <= 6), "segmen panjang diiris");
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 membungkus baris");
714
- assert(visibleWidth(wrapTest[0]!) <= 15, "lebar baris wrap sesuai batas");
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 tepat 20 kolom");
718
- assert(cLine.startsWith("│") && cLine.endsWith("│"), "centerLine diawali dan diakhiri │");
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 dengan name dan provider",
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 konversi id jadi titlecase",
704
+ "formatModelDisplayName converts id to titlecase",
730
705
  );
731
706
 
732
707
  // Package & version resolver
733
- assert(packageNameOf("@bismawy/pi-agentrouter") === "@bismawy/pi-agentrouter", "label npm polos");
734
- assert(packageNameOf("footer.ts") === "footer.ts", "label lokal");
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", "uji/arnative"), { recursive: true });
738
- writeFileSync(join(gitTmp, "git", "github.com", "uji/arnative", "package.json"), JSON.stringify({ version: "9.9.9" }));
739
- assert(installedVersion("uji/arnative", join(gitTmp, "npm"), join(gitTmp, "git")) === "9.9.9", "versi dari klon git");
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 adalah Directory");
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 direktori tampil sejak tab pertama dibuka");
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 baris logo + border (3 baris info mengisi 3 baris logo)");
772
- assert(lines100.every((l) => visibleWidth(l) === 100), "semua baris render 100 tepat 100 kolom");
773
- assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "border atas rounded");
774
- assert(lines100[7]!.includes("╰") && lines100[7]!.includes("╯"), "border bawah rounded");
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, "ascii Pi asli utuh (6 baris x 13 kolom)");
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
- "ascii Pi asli di kolom kiri, 6 baris",
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
- "baris 1: sapaan pakai username device",
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
- "baris 2: pi + Arnative + versi build Arnative",
762
+ "line 2: pi + Arnative + the Arnative build version",
788
763
  );
789
- assert(/╭─ Menu ─+╮/.test(stripAnsi(lines100[3]!)), "border atas kotak tab memuat label Menu");
790
- assert(!stripAnsi(lines100[1]!).includes("Interrupt"), "legend pintasan tidak lagi di baris atas");
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"), "nama pi pakai aksen tema");
798
- assert(accentLine.includes("<A>\x1b[1mArnative"), "nama Arnative pakai aksen tema yang sama");
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,"), "kata sapaan pakai warna dim");
801
- assert(greetLine.includes(`<A>\x1b[1m${deviceUser()}`), "username device pakai aksen tema");
802
- assert(stripAnsi(lines100[4]!).includes("Model [Gemini 3.8 Flash]"), "menu tab Model tampil (Model [nama])");
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 Directory tampil di menu");
807
- assert(!stripAnsi(lines100[4]!).includes("Directory: /"), "menu Directory tanpa path (detail hanya saat diklik)");
808
- assert(stripAnsi(lines100[4]!).indexOf("Directory") < stripAnsi(lines100[4]!).indexOf("Model ["), "Directory tab berada paling kiri");
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)"), "data tab aktif Model tampil di baris info terakhir");
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"), "path lengkap tab Directory (awal) tampil");
817
- assert(dirContent.includes("pi-arnative"), "path lengkap tab Directory (akhir) tampil — tanpa truncate");
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, 'garis kotak bawah (baris 5) = satu blok dim penuh');
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
- 'border atas kotak tab = dua blok dim dengan label " Menu " di antaranya',
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, "posisi Y baris tab tercatat");
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, "region tab terisi (tab kanan bisa terpotong di 100 kolom)");
833
- assert(tabRegions.some((r) => r.key === "Directory"), "tab Directory ada di menu (lebar 100)");
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, "klik mouse pada tab handled");
844
- assert(hdr.activeTab === extRegion.key, "activeTab berubah ke tab yang diklik");
845
- assert(renderRequested === true, "requestRender dipanggil saat tab berganti");
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" : ""), "data tab aktif tampil di baris info terakhir");
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 berubah ke ${skillsRegion.key} setelah klik`);
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), "baris 140 kolom tetap 140");
867
- assert(wideRegions.length === 6, "lebar 140: 6 tab pertama tampil (Shortcut di paling kanan ikut terbuang)");
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"), "data tab Skills tampil di baris info terakhir");
870
- assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data Model tidak tampil di konten tab Skills");
871
- assert(linesSkills[4]!.includes("Model [Gemini 3.8 Flash]"), "menu tab tetap tampil saat tab lain aktif");
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 menunjuk baris menu tab");
877
- assert(tabRegions.every((r) => rowsPlain[tabY]!.slice(r.startX, r.endX + 1).includes(r.key === "Model" ? "Model [" : r.key)), "region tab rata dengan teks di baris itu");
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
- "lebar 170: tab Shortcut tampil (tidak terbuang)",
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 Shortcut: legend pindah ke konten");
888
- assert(scContent.includes("[Ctrl+alt+r] Reload"), "tab Shortcut: pintasan reload ada di konten");
889
- assert(stripAnsi(linesShortcut[2]!).includes("Arnative v"), "baris 2 tetap brand Arnative saat tab Shortcut aktif");
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), "baris 60 kolom tetap 60");
896
- assert(stripAnsi(narrow[1]!).includes("Welcome back,"), "sapaan tetap utuh di 60 kolom");
897
- assert(stripAnsi(narrow[2]!).includes("Arnative v"), "brand pi + Arnative utuh di 60 kolom");
898
- assert(stripAnsi(narrow[2]!).includes("Interrupt") === false, "legend tidak balik ke baris atas");
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 paling kanan dibuang di 60 kolom",
876
+ "rightmost tab dropped at 60 columns",
902
877
  );
903
- assert(stripAnsi(narrow[4]!).includes("Model ["), "tab Model tetap tampil di 60 kolom");
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 di loadedResourcesContainer di-suppress (render = [])");
916
- assert(tabStore.get("Skills")?.includes("new-skill-1") === true, "data baru masuk ke tabStore");
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
  }
@@ -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 = (this as { _arnativeTimestamp?: number })._arnativeTimestamp ?? USER_TIMESTAMPS_MAP.get(textKey);
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.replace(/\x1b\[[0-9;]*m/g, "").trimEnd();
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.replace(/\x1b\[[0-9;]*m/g, "").trimEnd();
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 = typeof event.message.content === "string"
171
- ? event.message.content
172
- : event.message.content?.filter((c) => c.type === "text").map((c) => (c as { text: string }).text).join("") ?? "";
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 = ((ctx as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme) ?? 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
- const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
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, "panjang baris pas selebar terminal");
199
- assert(res1.endsWith(badge + " "), "badge berjarak 1 spasi dari ujung kanan");
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"), "jam diikuti spasi margin lalu reset bg");
205
- assert(res2.endsWith("\x1b[49m"), "background ditutup di akhir baris");
206
- assert(visibleWidth(res2) === 30, "panjang baris tetap pas lebar");
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 dipasang ulang setelah reset");
212
- assert(clean.endsWith("\x1b[49m"), "bg bubble ditutup di akhir baris");
213
- assert(repairBubbleBg("plain", "") === "plain", "tanpa bgOpen: tanpa perubahan");
214
- assert(ansiBgOpen(null, "userMessageBg") === "", "tanpa tema: bgOpen kosong");
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 terambil dari probe tema",
249
+ "bgOpen taken from the theme probe",
218
250
  );
219
251
 
220
- // Long text: sliceByColumn keeps the width intact
221
- const res3 = placeTimeAtRight("a".repeat(40), 30, badge, badgeW, false);
222
- assert(visibleWidth(res3) === 30, "teks panjang dipotong pas di targetCol");
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
  }