@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.
@@ -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 (version/shortcuts, tabs, data).
5
- * - Info line 1: pi version & shortcuts [Esc], [Ctrl+c/d], [/], [!], [Ctrl+o].
6
- * - Interactive tab menu (mouse click): Model + Context/Skills/Extensions/Themes
7
- * with item counts. Tab icon = accent, tab text = tint.
8
- * - Box divider '├────┤'.
9
- * - Active tab content (left-aligned, neatly wrapped, tint, never stacked).
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 { tmpdir } from "node:os";
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)).replace(ANSI_RE, "").trim();
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) + 2 + visibleWidth(item) <= maxWidth) {
155
- cur += ", " + item;
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
- if (!model) return "No Model";
256
- let name = model.name || model.id || "Model";
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
- // 1. pi version + brand line. Always intact.
444
+ // 2. Brand line: pi · Arnative, both in the theme accent, versions dim.
409
445
  const versionStr = VERSION || "0.87.1";
410
- const piPart = `${fgFirst(th, ["accent", "tint"], "\x1b[1mpi\x1b[22m")} ${fgFirst(th, ["dim"], `v${versionStr}`)}`;
411
- infoLines.push(`${piPart}${fgFirst(th, ["dim"], " · ")}${fgFirst(th, ["dim"], "Arnative")}`);
412
- const shortcutItems = [
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 => fgFirst(th, ["dim"], `${l}${"─".repeat(tabInnerW)}${r}`);
474
- infoLines.push(tabRule("╭", "╮"));
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"], "(kosong)"));
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 as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme ?? activeThemeProxy;
618
- // Daftarkan Custom Header
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
- const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
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] terdeteksi");
649
- 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");
650
675
 
651
676
  // Parsing & wrapping
652
- assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody koma = 3");
653
- 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");
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 tak menghilangkan segmen");
658
- assert(wp.every((l) => visibleWidth(l) <= 20), "wrapPath hormati lebar");
659
- assert(wrapPath("D:\\Pi\\pi-arnative", 80).join("") === "D:/Pi/pi-arnative", "wrapPath normalisasi backslash");
660
- 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");
661
686
 
662
687
  const wrapTest = wrapCommaItems(["item1", "item2", "item3", "item4"], 15);
663
- assert(wrapTest.length >= 2, "wrapCommaItems membungkus baris");
664
- 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");
665
690
 
666
691
  const cLine = centerLine("test", 20);
667
- assert(visibleWidth(cLine) === 20, "centerLine tepat 20 kolom");
668
- 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 │");
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 dengan name dan provider",
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 konversi id jadi titlecase",
704
+ "formatModelDisplayName converts id to titlecase",
680
705
  );
681
706
 
682
707
  // Package & version resolver
683
- assert(packageNameOf("@bismawy/pi-agentrouter") === "@bismawy/pi-agentrouter", "label npm polos");
684
- 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");
685
710
 
686
711
  const gitTmp = mkdtempSync(join(tmpdir(), "pi-ver-"));
687
- mkdirSync(join(gitTmp, "git", "github.com", "uji/arnative"), { recursive: true });
688
- writeFileSync(join(gitTmp, "git", "github.com", "uji/arnative", "package.json"), JSON.stringify({ version: "9.9.9" }));
689
- 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");
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 adalah Directory");
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 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");
718
743
 
719
744
  hdr.activeTab = "Model";
720
745
  const lines100 = hdr.render(100);
721
- assert(lines100.length === 8, "render 100 = border + 6 baris logo + border (3 baris info mengisi 3 baris logo)");
722
- assert(lines100.every((l) => visibleWidth(l) === 100), "semua baris render 100 tepat 100 kolom");
723
- assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "border atas rounded");
724
- 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");
725
750
  const logoPlain = buildLogoLines(null);
726
- 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)");
727
752
  assert(
728
753
  lines100.slice(1, 7).every((l, i) => stripAnsi(l).slice(2, 15) === logoPlain[i]),
729
- "ascii Pi asli di kolom kiri, 6 baris",
754
+ "real Pi ascii in the left column, 6 lines",
730
755
  );
731
- assert(stripAnsi(lines100[1]!).includes("pi v") && stripAnsi(lines100[1]!).includes("Arnative"), "baris versi + merek");
732
- assert(stripAnsi(lines100[4]!).includes("Model [Gemini 3.8 Flash]"), "menu tab Model tampil (Model [nama])");
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 Directory tampil di menu");
737
- assert(!stripAnsi(lines100[4]!).includes("Directory: /"), "menu Directory tanpa path (detail hanya saat diklik)");
738
- 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");
739
784
  hdr.activeTab = "Model";
740
785
  hdr.render(100);
741
- 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");
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"), "path lengkap tab Directory (awal) tampil");
747
- 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");
748
793
  hdr.activeTab = "Model";
749
794
  hdr.render(100);
750
- // The inner box lines are one fully dimmed block (the "─" runs are colored too, not just the corners).
751
- for (const i of [3, 5]) {
752
- assert((lines100[i]!.match(/\x1b\[/g) ?? []).length === 8, `garis kotak dalam baris ${i} = satu blok dim penuh (garis "─" ikut ter-warnai, bukan hanya sudut)`);
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, "posisi Y baris tab tercatat");
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, "region tab terisi (tab kanan bisa terpotong di 100 kolom)");
760
- 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)");
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, "klik mouse pada tab handled");
771
- assert(hdr.activeTab === extRegion.key, "activeTab berubah ke tab yang diklik");
772
- 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");
773
821
 
774
822
  const linesExt = hdr.render(100);
775
- 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");
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 berubah ke ${skillsRegion.key} setelah klik`);
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), "baris 140 kolom tetap 140");
794
- assert(wideRegions.length === 6, "lebar 140: semua 6 tab tampil");
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"), "data tab Skills tampil di baris info terakhir");
797
- assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data Model tidak tampil di konten tab Skills");
798
- 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");
799
847
  }
800
848
 
801
- // The Y of a tab must point at the tab menu line, not the shortcut line above
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 menunjuk baris menu tab");
804
- 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");
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: version + tabs + data stay intact, shortcuts get trimmed
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), "baris 60 kolom tetap 60");
809
- assert(stripAnsi(narrow[1]!).includes("pi v"), "versi tetap utuh di 60 kolom");
810
- assert(stripAnsi(narrow[1]!).includes("More/expand") === false, "shortcut panjang dibuang di 60 kolom");
811
- assert(stripAnsi(narrow[4]!).includes("Model ["), "tab Model tetap tampil di 60 kolom");
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 di loadedResourcesContainer di-suppress (render = [])");
824
- 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");
825
892
 
826
893
  console.log("section-headers.ts self-check OK");
827
894
  }