@bismawy/pi-arnative 0.2.2 → 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 Directory, 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 (Directory → Model → 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[] = ["Directory", "Model", "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
  }
@@ -350,6 +392,13 @@ export class ArnativeHeader implements Component {
350
392
  count: tabStore.get("Themes")?.length ?? 0,
351
393
  label: `Themes [${tabStore.get("Themes")?.length ?? 0}]`,
352
394
  },
395
+ {
396
+ key: "Shortcut",
397
+ name: "Shortcut",
398
+ icon: SECTION_ICONS.Shortcut,
399
+ count: SHORTCUTS.length,
400
+ label: `Shortcut [${SHORTCUTS.length}]`,
401
+ },
353
402
  ];
354
403
  }
355
404
 
@@ -404,28 +453,17 @@ export class ArnativeHeader implements Component {
404
453
  const infoLines: string[] = [];
405
454
  // Inner box: the tab menu line is framed, content is (infoW - 2) columns.
406
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())}`);
407
461
 
408
- // 1. pi version + brand line. Always intact.
462
+ // 2. Brand line: pi · Arnative, both in the theme accent, versions dim.
409
463
  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);
464
+ infoLines.push(
465
+ `${brand("pi")} ${dim(`v${versionStr}`)}${dim(" · ")}${brand("Arnative")}${ARNATIVE_VERSION ? ` ${dim(`v${ARNATIVE_VERSION}`)}` : ""}`,
466
+ );
429
467
 
430
468
  // 2. Tab menu: icon = accent, text = tint (bold when active)
431
469
  const tabs = this.getTabsData(width);
@@ -470,8 +508,16 @@ export class ArnativeHeader implements Component {
470
508
  const t = truncateToWidth(text, tabInnerW);
471
509
  return `${side}${t}${" ".repeat(tabInnerW - visibleWidth(t))}${side}`;
472
510
  };
473
- const tabRule = (l: string, r: string): string => fgFirst(th, ["dim"], `${l}${"─".repeat(tabInnerW)}${r}`);
474
- 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 "));
475
521
  // One space from the left border, then " │ " between tabs.
476
522
  infoLines.push(tabRow(tabText));
477
523
  // Tab line = top border (index 0) + the infoLines above + this line.
@@ -516,6 +562,10 @@ export class ArnativeHeader implements Component {
516
562
  for (const line of wrapPath(getCwd(this.ctx), infoW)) {
517
563
  infoLines.push(fgFirst(th, ["tint", "text"], line));
518
564
  }
565
+ } else if (this.activeTab === "Shortcut") {
566
+ for (const line of wrapTokens(SHORTCUTS, infoW)) {
567
+ infoLines.push(fgFirst(th, ["tint", "text"], line));
568
+ }
519
569
  } else if (activeItems.length === 0) {
520
570
  infoLines.push(fgFirst(th, ["tint"], "(kosong)"));
521
571
  } else if (this.activeTab === "Model") {
@@ -728,7 +778,27 @@ if (isMain) {
728
778
  lines100.slice(1, 7).every((l, i) => stripAnsi(l).slice(2, 15) === logoPlain[i]),
729
779
  "ascii Pi asli di kolom kiri, 6 baris",
730
780
  );
731
- assert(stripAnsi(lines100[1]!).includes("pi v") && stripAnsi(lines100[1]!).includes("Arnative"), "baris versi + merek");
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");
732
802
  assert(stripAnsi(lines100[4]!).includes("Model [Gemini 3.8 Flash]"), "menu tab Model tampil (Model [nama])");
733
803
  // Directory tab: name only in the menu, whole path in the content line below
734
804
  hdr.activeTab = "Directory";
@@ -747,10 +817,13 @@ if (isMain) {
747
817
  assert(dirContent.includes("pi-arnative"), "path lengkap tab Directory (akhir) tampil — tanpa truncate");
748
818
  hdr.activeTab = "Model";
749
819
  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
- }
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
+ );
754
827
 
755
828
  // Tab regions: Model, Context, Skills, Extensions, Themes
756
829
  const tabY = (hdr as any).renderedTabLineY;
@@ -791,23 +864,42 @@ if (isMain) {
791
864
 
792
865
  const linesSkills = hdr.render(140);
793
866
  assert(wide.every((l) => visibleWidth(l) === 140), "baris 140 kolom tetap 140");
794
- 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)");
795
868
  if (skillsRegion.key === "Skills") {
796
869
  assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "data tab Skills tampil di baris info terakhir");
797
870
  assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data Model tidak tampil di konten tab Skills");
798
871
  assert(linesSkills[4]!.includes("Model [Gemini 3.8 Flash]"), "menu tab tetap tampil saat tab lain aktif");
799
872
  }
800
873
 
801
- // 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
802
875
  const rowsPlain = lines100.map((l) => stripAnsi(l));
803
876
  assert(rowsPlain[tabY]!.includes("Model ["), "renderedTabLineY menunjuk baris menu tab");
804
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");
805
878
 
806
- // Narrow screen: version + tabs + data stay intact, shortcuts get trimmed
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);
892
+
893
+ // Narrow screen: brand lines + tabs + data stay intact, rightmost tabs are dropped
807
894
  const narrow = hdr.render(60);
808
895
  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");
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
+ );
811
903
  assert(stripAnsi(narrow[4]!).includes("Model ["), "tab Model tetap tampil di 60 kolom");
812
904
 
813
905
  // Intercept addChild: capture the data and suppress the child
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.2.2",
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",