@bismawy/pi-arnative 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -11,7 +11,7 @@ A visual layer and tool styling tailored to your vibe coding.
11
11
  pi-arnative replaces pi’s default terminal chrome with a unified, cohesive visual aesthetic.
12
12
 
13
13
  - Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
14
- - Tabbed Header: Compact box menu with quick access to Model, Skills, Extensions, and Themes.
14
+ - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, Themes, and Shortcut.
15
15
  - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call.
16
16
  - Enhanced Footer: 2-line layout showing directory path, git branch, model, and token metrics.
17
17
  - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
@@ -36,11 +36,27 @@ pi --extension ./extensions/footer.ts
36
36
  | Key / Action | Result |
37
37
  | --- | --- |
38
38
  | `/usage` | Show session usage & token metrics |
39
- | `ctrl+alt+t` | Cycle header tabs (Model → Directory → Context → Skills → Extensions → Themes) |
39
+ | `ctrl+alt+t` | Cycle header tabs (Directory → Model → Context → Skills → Extensions → Themes → Shortcut) |
40
+ | `ctrl+alt+n` | Start a new session (`/new`) — see [Keybindings](#keybindings) |
40
41
  | `ctrl+alt+r` | Quick reload runtime (`/reload`) |
41
42
  | Click tab | Open selected header tab |
42
43
  | Click tool / `ctrl+e` | Toggle tool output box |
43
44
 
45
+ Same cheatsheet in the header's **Shortcut** tab; on narrow terminals the rightmost tabs drop off first.
46
+
47
+ ## Keybindings
48
+
49
+ `ctrl+alt+n` is a plain user keybinding, not a package shortcut — pi-arnative never registers it.
50
+ Add it once to `~/.pi/agent/keybindings.json`:
51
+
52
+ ```json
53
+ {
54
+ "app.session.new": "ctrl+alt+n"
55
+ }
56
+ ```
57
+
58
+ Restart pi to pick it up.
59
+
44
60
  ## Architecture
45
61
 
46
62
  <details>
@@ -2,15 +2,19 @@
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
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.
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 ───╮").
8
+ * - 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.
8
11
  * - Box divider '├────┤'.
9
12
  * - Active tab content (left-aligned, neatly wrapped, tint, never stacked).
10
13
  * - Hides pi's built-in stacked list in loadedResourcesContainer.
11
14
  */
12
15
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
- import { tmpdir } from "node:os";
16
+ import { fileURLToPath } from "node:url";
17
+ import { tmpdir, userInfo } from "node:os";
14
18
  import { join } from "node:path";
15
19
  import {
16
20
  UserMessageComponent,
@@ -31,10 +35,10 @@ import {
31
35
  import { renderBoxLines } from "../lib/box.ts";
32
36
  import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
33
37
 
34
- export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes";
38
+ export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes" | "Shortcut";
35
39
 
36
40
  // Tab order for Ctrl+Alt+T and for the menu line.
37
- export const TAB_ORDER: TabKey[] = ["Model", "Directory", "Context", "Skills", "Extensions", "Themes"];
41
+ export const TAB_ORDER: TabKey[] = ["Directory", "Model", "Context", "Skills", "Extensions", "Themes", "Shortcut"];
38
42
 
39
43
  export const SECTION_ICONS: Record<TabKey, string> = {
40
44
  Model: "\uf1b2",
@@ -43,6 +47,7 @@ export const SECTION_ICONS: Record<TabKey, string> = {
43
47
  Skills: "\uec21",
44
48
  Extensions: "\ueea8",
45
49
  Themes: "\uee72",
50
+ Shortcut: "\uf11c", // nf-fa-keyboard_o
46
51
  };
47
52
 
48
53
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
@@ -88,6 +93,21 @@ export function packageNameOf(label: string): string | null {
88
93
  return /^[\w.@/-]+$/.test(base) ? base : null;
89
94
  }
90
95
 
96
+ /**
97
+ * OS account name of whoever is running pi, for the header greeting. Falls back to the
98
+ * usual env vars, then to a neutral word so the line is never empty.
99
+ */
100
+ export function deviceUser(): string {
101
+ try {
102
+ const u = userInfo().username;
103
+ if (u) return u;
104
+ } catch {
105
+ /* no passwd entry (some containers) — fall through to env */
106
+ }
107
+ const u = process.env.USER ?? process.env.USERNAME ?? "";
108
+ return u || "there";
109
+ }
110
+
91
111
  const versionCache = new Map<string, string | null>();
92
112
 
93
113
  export function readVersion(dir: string): string | null {
@@ -122,6 +142,11 @@ export function installedVersion(
122
142
  return version;
123
143
  }
124
144
 
145
+ // Arnative build actually running: package.json one level above this extension file.
146
+ // Reading the module's own package beats reading the npm-installed copy, which lags
147
+ // behind whenever the local clone (or a hand-copied extensions/) is what pi loaded.
148
+ const ARNATIVE_VERSION = readVersion(fileURLToPath(new URL("..", import.meta.url)));
149
+
125
150
  export function extractItemsFromBody(body: string, isExtensions = false): string[] {
126
151
  const plain = stripAnsi(body).trim();
127
152
  if (!plain) return [];
@@ -145,16 +170,33 @@ export function extractItemsFromBody(body: string, isExtensions = false): string
145
170
  });
146
171
  }
147
172
 
173
+ // Cheatsheet for the Shortcut tab. Mirrors README "Shortcuts" plus the bindings the
174
+ // extensions themselves own (footer.ts). Kept as plain text so the data line can wrap it.
175
+ export const SHORTCUTS: string[] = [
176
+ "[Esc] Interrupt",
177
+ "[Ctrl+c/d] Exit",
178
+ "[/] Commands",
179
+ "[!] Bash",
180
+ "[Ctrl+o] More/expand",
181
+ "[Ctrl+alt+t] Next tab",
182
+ "[Ctrl+alt+n] New",
183
+ "[Ctrl+alt+r] Reload",
184
+ ];
185
+
148
186
  export function wrapCommaItems(items: string[], maxWidth: number): string[] {
187
+ return wrapTokens(items, maxWidth, ", ", ",");
188
+ }
189
+
190
+ export function wrapTokens(items: string[], maxWidth: number, sep = " ", trailing = ""): string[] {
149
191
  const lines: string[] = [];
150
192
  let cur = "";
151
193
  for (const item of items) {
152
194
  if (!cur) {
153
195
  cur = item;
154
- } else if (visibleWidth(cur) + 2 + visibleWidth(item) <= maxWidth) {
155
- cur += ", " + item;
196
+ } else if (visibleWidth(cur) + visibleWidth(sep) + visibleWidth(item) <= maxWidth) {
197
+ cur += sep + item;
156
198
  } else {
157
- lines.push(cur + ",");
199
+ lines.push(cur + trailing);
158
200
  cur = item;
159
201
  }
160
202
  }
@@ -276,7 +318,7 @@ export function getCwd(ctx?: { cwd?: string }): string {
276
318
  }
277
319
 
278
320
  export class ArnativeHeader implements Component {
279
- public activeTab: TabKey = "Model";
321
+ public activeTab: TabKey = "Directory";
280
322
  private renderedTabLineY = -1;
281
323
  private renderedTabRegions: Array<{ key: TabKey; startX: number; endX: number }> = [];
282
324
 
@@ -303,27 +345,25 @@ export class ArnativeHeader implements Component {
303
345
 
304
346
  getTabsData(width: number): Array<{ key: TabKey; name: string; icon: string; count: number; label: string }> {
305
347
  const modelObj = this.ctx?.model ?? (globalThis as Record<symbol, any>)[MODEL_SNAPSHOT_KEY];
306
- const cwd = getCwd(this.ctx);
307
- // Tab row stays compact (basename only): the full path is in the content
308
- // line below, and render() expands this label when every tab still fits.
309
- const dirBase = cwd.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() || cwd;
310
- const dirLabel = dirBase ? `Directory: ${dirBase}` : "Directory";
348
+ // Tab row stays compact: the Directory tab shows just its name, the full path
349
+ // lives in the content line below.
350
+ const dirLabel = "Directory";
311
351
  const fullModelName = formatModelDisplayName(modelObj);
312
352
  // Drop bracketed details from the tail of the name ("... Free (1M) [OpenCode] (Freeflow)"
313
353
  // -> "Space Bunny Free"); the details still show in the Model tab data line.
314
- let modelLabel = `Model: ${fullModelName.replace(/(\s*\([^()]*\)|\s*\[[^[\]]*\])+$/, "").trim()}`;
354
+ let modelLabel = `Model [${fullModelName.replace(/(\s*\([^()]*\)|\s*\[[^[\]]*\])+$/, "").trim()}]`;
315
355
  // Responsive on medium screens: drop the provider when columns < 112
316
356
  if (width < 112 && modelLabel.includes(" (")) {
317
- modelLabel = `Model: ${fullModelName.slice(0, fullModelName.indexOf(" (")).trim()}`;
357
+ modelLabel = `Model [${fullModelName.slice(0, fullModelName.indexOf(" (")).trim()}]`;
318
358
  }
319
359
  // Below 88 columns: shorten the model name
320
360
  if (width < 88 && modelLabel.length > 18) {
321
- modelLabel = `Model: ${modelLabel.slice(7, 18).trim()}…`;
361
+ modelLabel = `Model [${modelLabel.slice(7, 18).trim()}…]`;
322
362
  }
323
363
 
324
364
  return [
325
- { key: "Model", name: "Model", icon: SECTION_ICONS.Model, count: 0, label: modelLabel },
326
365
  { key: "Directory", name: "Directory", icon: SECTION_ICONS.Directory, count: 0, label: dirLabel },
366
+ { key: "Model", name: "Model", icon: SECTION_ICONS.Model, count: 0, label: modelLabel },
327
367
  {
328
368
  key: "Context",
329
369
  name: "Context",
@@ -352,6 +392,13 @@ export class ArnativeHeader implements Component {
352
392
  count: tabStore.get("Themes")?.length ?? 0,
353
393
  label: `Themes [${tabStore.get("Themes")?.length ?? 0}]`,
354
394
  },
395
+ {
396
+ key: "Shortcut",
397
+ name: "Shortcut",
398
+ icon: SECTION_ICONS.Shortcut,
399
+ count: SHORTCUTS.length,
400
+ label: `Shortcut [${SHORTCUTS.length}]`,
401
+ },
355
402
  ];
356
403
  }
357
404
 
@@ -406,27 +453,17 @@ export class ArnativeHeader implements Component {
406
453
  const infoLines: string[] = [];
407
454
  // Inner box: the tab menu line is framed, content is (infoW - 2) columns.
408
455
  const innerW = Math.max(1, infoW - 2);
456
+ const brand = (name: string) => fgFirst(th, ["accent", "tint"], `\x1b[1m${name}\x1b[22m`);
457
+ const dim = (text: string) => fgFirst(th, ["dim"], text);
458
+
459
+ // 1. Greeting: device username, accent like the brand names below it.
460
+ infoLines.push(`${dim("Welcome back,")} ${brand(deviceUser())}`);
409
461
 
410
- // 1. pi version + brand line. Always intact.
462
+ // 2. Brand line: pi · Arnative, both in the theme accent, versions dim.
411
463
  const versionStr = VERSION || "0.87.1";
412
- const piPart = `${fgFirst(th, ["accent", "tint"], "\x1b[1mpi\x1b[22m")} ${fgFirst(th, ["dim"], `v${versionStr}`)}`;
413
- infoLines.push(`${piPart}${fgFirst(th, ["dim"], " · ")}${fgFirst(th, ["dim"], "Arnative")}`);
414
- const shortcutItems = [
415
- `${fgFirst(th, ["tint"], "[Esc]")} ${fgFirst(th, ["dim"], "Interrupt")}`,
416
- `${fgFirst(th, ["tint"], "[Ctrl+c/d]")} ${fgFirst(th, ["dim"], "Exit")}`,
417
- `${fgFirst(th, ["tint"], "[/]")} ${fgFirst(th, ["dim"], "Commands")}`,
418
- `${fgFirst(th, ["tint"], "[!]")} ${fgFirst(th, ["dim"], "Bash")}`,
419
- `${fgFirst(th, ["tint"], "[Ctrl+o]")} ${fgFirst(th, ["dim"], "More/expand")}`,
420
- `${fgFirst(th, ["tint"], "[Ctrl+alt+t]")} ${fgFirst(th, ["dim"], "Next tab")}`,
421
- ];
422
- // Shortcut list is trimmed from the right (longest prefix that still fits) so
423
- // narrow screens never clip the tabs or the data.
424
- let scText = "";
425
- for (let i = 1; i <= shortcutItems.length; i++) {
426
- const cand = shortcutItems.slice(0, i).join(" ");
427
- if (visibleWidth(cand) <= infoW) scText = cand;
428
- }
429
- infoLines.push(scText);
464
+ infoLines.push(
465
+ `${brand("pi")} ${dim(`v${versionStr}`)}${dim(" · ")}${brand("Arnative")}${ARNATIVE_VERSION ? ` ${dim(`v${ARNATIVE_VERSION}`)}` : ""}`,
466
+ );
430
467
 
431
468
  // 2. Tab menu: icon = accent, text = tint (bold when active)
432
469
  const tabs = this.getTabsData(width);
@@ -434,7 +471,8 @@ export class ArnativeHeader implements Component {
434
471
  const sepTab = fgFirst(th, ["dim"], sepTabPlain);
435
472
 
436
473
  const tabParts: Array<{ key: TabKey; plain: string; formatted: string }> = [];
437
- const makeTabPart = (t: { key: TabKey; icon: string; label: string }, label = t.label) => {
474
+ const makeTabPart = (t: { key: TabKey; icon: string; label: string }) => {
475
+ const { label } = t;
438
476
  const plain = `${t.icon} ${label}`;
439
477
  const isActive = t.key === this.activeTab;
440
478
  const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
@@ -455,22 +493,9 @@ export class ArnativeHeader implements Component {
455
493
  usedWidth += w;
456
494
  }
457
495
  }
458
- // All tabs fit? Then the Directory tab may show the whole path instead of the
459
- // basename. Skipped when it would push another tab off the menu.
460
- if (kept.length === tabParts.length) {
461
- const i = kept.findIndex((t) => t.key === "Directory");
462
- const cwdFull = getCwd(this.ctx);
463
- const dirTab = tabs[i];
464
- if (i >= 0 && dirTab && cwdFull) {
465
- const full = makeTabPart(dirTab, `Directory: ${cwdFull}`);
466
- if (usedWidth - visibleWidth(kept[i]!.plain) + visibleWidth(full.plain) <= innerW) {
467
- kept[i] = full;
468
- usedWidth += visibleWidth(full.plain) - visibleWidth(tabParts[i]!.plain);
469
- }
470
- }
471
- }
472
496
  this.renderedTabRegions = [];
473
- let curX = INFO_X;
497
+ // The tab row has its own left border + a leading space before the first tab.
498
+ let curX = INFO_X + 2;
474
499
  for (const t of kept) {
475
500
  const w = visibleWidth(t.plain);
476
501
  this.renderedTabRegions.push({ key: t.key, startX: curX, endX: curX + w - 1 });
@@ -483,8 +508,16 @@ export class ArnativeHeader implements Component {
483
508
  const t = truncateToWidth(text, tabInnerW);
484
509
  return `${side}${t}${" ".repeat(tabInnerW - visibleWidth(t))}${side}`;
485
510
  };
486
- const tabRule = (l: string, r: string): string => fgFirst(th, ["dim"], `${l}${"─".repeat(tabInnerW)}${r}`);
487
- infoLines.push(tabRule("╭", "╮"));
511
+ const tabRule = (l: string, r: string, label = ""): string => {
512
+ // Label rides on the top border ("╭─ Menu ───╮") so no extra text line is spent; the
513
+ // word is tint so it reads as a label instead of a dimmer stretch of border.
514
+ const lead = label.length + 1;
515
+ if (label && tabInnerW - lead > 1) {
516
+ return `${fgFirst(th, ["dim"], `${l}─`)}${fgFirst(th, ["tint", "text"], label)}${fgFirst(th, ["dim"], `${"─".repeat(tabInnerW - lead)}${r}`)}`;
517
+ }
518
+ return fgFirst(th, ["dim"], `${l}${"─".repeat(tabInnerW)}${r}`);
519
+ };
520
+ infoLines.push(tabRule("╭", "╮", " Menu "));
488
521
  // One space from the left border, then " │ " between tabs.
489
522
  infoLines.push(tabRow(tabText));
490
523
  // Tab line = top border (index 0) + the infoLines above + this line.
@@ -529,6 +562,10 @@ export class ArnativeHeader implements Component {
529
562
  for (const line of wrapPath(getCwd(this.ctx), infoW)) {
530
563
  infoLines.push(fgFirst(th, ["tint", "text"], line));
531
564
  }
565
+ } else if (this.activeTab === "Shortcut") {
566
+ for (const line of wrapTokens(SHORTCUTS, infoW)) {
567
+ infoLines.push(fgFirst(th, ["tint", "text"], line));
568
+ }
532
569
  } else if (activeItems.length === 0) {
533
570
  infoLines.push(fgFirst(th, ["tint"], "(kosong)"));
534
571
  } else if (this.activeTab === "Model") {
@@ -723,8 +760,13 @@ if (isMain) {
723
760
  // Only "dim" is colored, so the box line colors can be asserted.
724
761
  const ansiTheme = { fg: (c: string, t: string) => (c === "dim" ? `\x1b[2m${t}\x1b[22m` : t) };
725
762
  const hdr = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
726
- assert(hdr.activeTab === "Model", "default tab adalah Model");
763
+ assert(hdr.activeTab === "Directory", "default tab adalah Directory");
764
+
765
+ // Default view: the working directory path, shown on open
766
+ 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");
727
768
 
769
+ hdr.activeTab = "Model";
728
770
  const lines100 = hdr.render(100);
729
771
  assert(lines100.length === 8, "render 100 = border + 6 baris logo + border (3 baris info mengisi 3 baris logo)");
730
772
  assert(lines100.every((l) => visibleWidth(l) === 100), "semua baris render 100 tepat 100 kolom");
@@ -736,12 +778,34 @@ if (isMain) {
736
778
  lines100.slice(1, 7).every((l, i) => stripAnsi(l).slice(2, 15) === logoPlain[i]),
737
779
  "ascii Pi asli di kolom kiri, 6 baris",
738
780
  );
739
- assert(stripAnsi(lines100[1]!).includes("pi v") && stripAnsi(lines100[1]!).includes("Arnative"), "baris versi + merek");
740
- assert(stripAnsi(lines100[4]!).includes("Model: Gemini 3.8 Flash"), "menu tab Model tampil");
741
- // Directory tab: whole path in the content line, no truncation
781
+ assert(
782
+ /Welcome back, \S/.test(stripAnsi(lines100[1]!)) && !stripAnsi(lines100[1]!).includes("pi v"),
783
+ "baris 1: sapaan pakai username device",
784
+ );
785
+ assert(
786
+ stripAnsi(lines100[2]!).includes("pi v") && /Arnative v\d+\.\d+\.\d+/.test(stripAnsi(lines100[2]!)),
787
+ "baris 2: pi + Arnative + versi build Arnative",
788
+ );
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");
791
+ // Both brand names use the theme accent, not only "pi".
792
+ const accentTheme = {
793
+ fg: (c: string, t: string) => (c === "accent" ? `<A>${t}</A>` : c === "dim" ? `<D>${t}</D>` : t),
794
+ };
795
+ const accentHeader = new ArnativeHeader(fakeTui, accentTheme as any, fakeCtx);
796
+ 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");
799
+ 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])");
803
+ // Directory tab: name only in the menu, whole path in the content line below
742
804
  hdr.activeTab = "Directory";
743
805
  hdr.render(100);
744
- assert(stripAnsi(lines100[4]!).includes("Directory:"), "tab Directory tampil di menu");
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");
745
809
  hdr.activeTab = "Model";
746
810
  hdr.render(100);
747
811
  assert(stripAnsi(lines100[6]!).includes("Gemini 3.8 Flash (Antigravity)"), "data tab aktif Model tampil di baris info terakhir");
@@ -753,10 +817,13 @@ if (isMain) {
753
817
  assert(dirContent.includes("pi-arnative"), "path lengkap tab Directory (akhir) tampil — tanpa truncate");
754
818
  hdr.activeTab = "Model";
755
819
  hdr.render(100);
756
- // The inner box lines are one fully dimmed block (the "─" runs are colored too, not just the corners).
757
- for (const i of [3, 5]) {
758
- assert((lines100[i]!.match(/\x1b\[/g) ?? []).length === 8, `garis kotak dalam baris ${i} = satu blok dim penuh (garis "─" ikut ter-warnai, bukan hanya sudut)`);
759
- }
820
+ // Inner box rules: bottom line is one fully dimmed block (the "─" runs are colored too, not
821
+ // 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');
823
+ assert(
824
+ 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',
826
+ );
760
827
 
761
828
  // Tab regions: Model, Context, Skills, Extensions, Themes
762
829
  const tabY = (hdr as any).renderedTabLineY;
@@ -797,24 +864,43 @@ if (isMain) {
797
864
 
798
865
  const linesSkills = hdr.render(140);
799
866
  assert(wide.every((l) => visibleWidth(l) === 140), "baris 140 kolom tetap 140");
800
- assert(wideRegions.length === 6, "lebar 140: semua 6 tab tampil");
867
+ assert(wideRegions.length === 6, "lebar 140: 6 tab pertama tampil (Shortcut di paling kanan ikut terbuang)");
801
868
  if (skillsRegion.key === "Skills") {
802
869
  assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "data tab Skills tampil di baris info terakhir");
803
870
  assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data Model tidak tampil di konten tab Skills");
804
- assert(linesSkills[4]!.includes("Model: Gemini 3.8 Flash"), "menu tab tetap tampil saat tab lain aktif");
871
+ assert(linesSkills[4]!.includes("Model [Gemini 3.8 Flash]"), "menu tab tetap tampil saat tab lain aktif");
805
872
  }
806
873
 
807
- // The Y of a tab must point at the tab menu line, not the shortcut line above
874
+ // The Y of a tab must point at the tab menu line, not the line above
808
875
  const rowsPlain = lines100.map((l) => stripAnsi(l));
809
- assert(rowsPlain[tabY]!.includes("Model:"), "renderedTabLineY menunjuk baris menu tab");
810
- 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");
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");
878
+
879
+ // Shortcut tab: the key cheatsheet lives in the tab content, not in a header line
880
+ hdr.activeTab = "Shortcut";
881
+ const linesShortcut = hdr.render(170);
882
+ assert(
883
+ ((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
884
+ "lebar 170: tab Shortcut tampil (tidak terbuang)",
885
+ );
886
+ 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");
890
+ hdr.activeTab = "Model";
891
+ hdr.render(140);
811
892
 
812
- // Narrow screen: version + tabs + data stay intact, shortcuts get trimmed
893
+ // Narrow screen: brand lines + tabs + data stay intact, rightmost tabs are dropped
813
894
  const narrow = hdr.render(60);
814
895
  assert(narrow.every((l) => visibleWidth(l) === 60), "baris 60 kolom tetap 60");
815
- assert(stripAnsi(narrow[1]!).includes("pi v"), "versi tetap utuh di 60 kolom");
816
- assert(stripAnsi(narrow[1]!).includes("More/expand") === false, "shortcut panjang dibuang di 60 kolom");
817
- assert(stripAnsi(narrow[4]!).includes("Model:"), "tab Model tetap tampil di 60 kolom");
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");
899
+ assert(
900
+ !((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
901
+ "tab paling kanan dibuang di 60 kolom",
902
+ );
903
+ assert(stripAnsi(narrow[4]!).includes("Model ["), "tab Model tetap tampil di 60 kolom");
818
904
 
819
905
  // Intercept addChild: capture the data and suppress the child
820
906
  const proto = Object.getPrototypeOf(UserMessageComponent.prototype) as { addChild?: unknown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Arnative look for Pi: 11 warm-neutral themes, rounded tool boxes, branded header with tabs, 2-line footer with git/model/status, transcript clock, and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",