@bismawy/pi-arnative 0.2.12 → 0.3.1

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,13 +1,14 @@
1
1
  /**
2
2
  * Arnative header box & horizontal tab system:
3
- * - Round-corner box, fixed height 5 lines: "pi" 3x6 logo on the left, a vertical
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
3
+ * - Round-corner box: left column = the pi block mark (4x8, P accent / i soft) with
4
+ * "pi vX" under it; a vertical divider; then the info column = "Arnative vY
5
+ * · Welcome back, <User>", the tab menu, the tab data.
6
+ * - Greeting = OS account name (first letter capitalized). The tab box has no label;
7
+ * its column dividers join the borders with ┬/┴ (full height).
8
+ * - Interactive tab menu (mouse click): Model + Context/Skills/Extensions/Shortcut
9
+ * with item counts. Icon = accent, text = soft. The key cheatsheet lives in the
9
10
  * Shortcut tab, not in a header line.
10
- * - Box divider '├────┤'. Active tab content is left-aligned, wrapped, tint.
11
+ * - Box divider '├────┤'. Active tab content is left-aligned, wrapped, soft.
11
12
  * - Hides pi's built-in stacked list in loadedResourcesContainer.
12
13
  */
13
14
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
@@ -32,14 +33,14 @@ import {
32
33
  import { renderBoxLines } from "../lib/box.ts";
33
34
  import { stripAnsi, themeOf } from "../lib/ansi.ts";
34
35
  import { assert, isMain } from "../lib/check.ts";
35
- import { capitalize, modelDisplayParts } from "../lib/format.ts";
36
+ import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
36
37
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
37
38
  import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
38
39
 
39
- export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Themes" | "Shortcut";
40
+ export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Shortcut";
40
41
 
41
42
  // Tab order for the next-tab shortcut and for the menu line.
42
- export const TAB_ORDER: TabKey[] = ["Directory", "Model", "Context", "Skills", "Extensions", "Themes", "Shortcut"];
43
+ export const TAB_ORDER: TabKey[] = ["Directory", "Model", "Context", "Skills", "Extensions", "Shortcut"];
43
44
 
44
45
  export const SECTION_ICONS: Record<TabKey, string> = {
45
46
  Model: "\uf1b2",
@@ -47,7 +48,6 @@ export const SECTION_ICONS: Record<TabKey, string> = {
47
48
  Context: "\udb84\uddd7",
48
49
  Skills: "\uec21",
49
50
  Extensions: "\ueea8",
50
- Themes: "\uee72",
51
51
  Shortcut: "\uf11c", // nf-fa-keyboard_o
52
52
  };
53
53
 
@@ -94,12 +94,12 @@ export function themedTextOf(child: any): string {
94
94
  return typeof child?.getText === "function" ? String(child.getText()) : "";
95
95
  }
96
96
 
97
- export function sectionNameOf(text: string): "Context" | "Skills" | "Extensions" | "Themes" | null {
97
+ export function sectionNameOf(text: string): "Context" | "Skills" | "Extensions" | null {
98
98
  const nl = text.indexOf("\n");
99
99
  const first = stripAnsi(nl === -1 ? text : text.slice(0, nl)).trim();
100
100
  const m = /^\[([A-Za-z]+)\]$/.exec(first);
101
101
  const name = m ? m[1] : null;
102
- if (name === "Context" || name === "Skills" || name === "Extensions" || name === "Themes") {
102
+ if (name === "Context" || name === "Skills" || name === "Extensions") {
103
103
  return name;
104
104
  }
105
105
  return null;
@@ -268,30 +268,22 @@ export function centerLine(text: string, width: number, side = "│"): string {
268
268
  return side + " ".repeat(left) + text + " ".repeat(right) + side;
269
269
  }
270
270
 
271
- // pi.dev ASCII logo: P in the theme accent, i in the theme tint
271
+ // pi.dev block mark: the logo grid at terminal aspect (2 chars per column), 4 lines.
272
+ // P (the salmon/blue structure, columns 0-2) in the theme accent; i (the yellow
273
+ // bar, column 3) in the theme soft — same P/i split as the old wordmark.
272
274
  export function buildLogoLines(th: Themeish): string[] {
273
- const p = (s: string) => fgFirst(th, ["accent"], s);
274
- const i = (s: string) => fgFirst(th, ["tint", "text"], s);
275
-
275
+ const a = (s: string) => fgFirst(th, ["accent"], s);
276
+ const t = (s: string) => fgFirst(th, ["accentSoft", "text"], s);
276
277
  return [
277
- `${p("██████████")} `,
278
- `${p("██████████")} `,
279
- `${p("████ ███")} `,
280
- `${p("███████")} ${i("███")}`,
281
- `${p("███")} ${i("███")}`,
282
- `${p("███")} ${i("███")}`,
278
+ `${a("██████")} `,
279
+ `${a("██ ██")} `,
280
+ `${a("████")} ${t("██")}`,
281
+ `${a("██")} ${t("██")}`,
283
282
  ];
284
283
  }
285
284
 
286
285
  export const ASCII_LOGO_LINES = buildLogoLines(null);
287
286
 
288
- // Logo column width, derived from the art so it is not a magic number.
289
- const LOGO_W = Math.max(...ASCII_LOGO_LINES.map((l) => visibleWidth(l)));
290
- // Info area starts at column: "│ " (2) + logo (LOGO_W) + " │ " (3).
291
- const INFO_X = LOGO_W + 5;
292
- // Columns left for info: width - "│ " - logo - " │ " - info - " │".
293
- const infoWidth = (width: number) => Math.max(8, width - LOGO_W - 7);
294
-
295
287
  // Singleton store for loaded resources
296
288
  const STORE_KEY = Symbol.for("pi-arnative.resourceStore");
297
289
  export const tabStore: Map<TabKey, string[]> =
@@ -302,11 +294,48 @@ export const tabStore: Map<TabKey, string[]> =
302
294
  ["Context", []],
303
295
  ["Skills", []],
304
296
  ["Extensions", []],
305
- ["Themes", []],
306
297
  ]);
307
298
  (globalThis as Record<symbol, Map<TabKey, string[]>>)[STORE_KEY] = tabStore;
308
299
 
309
- let activeHeaderInstance: ArnativeHeader | null = null;
300
+ // Header preset chosen through `/arnative headers`. "Arnative (Full)" = this extension's
301
+ // header; "Pi (system)" = pi's built-in header. The picker lives in arnative.ts, so the
302
+ // choice is shared through globalThis like the other cross-module state.
303
+ // Listed built-in-first: the `(Full)` suffix is rendered dim, so the plain row reads cleaner on top.
304
+ export const HEADER_PRESETS = ["Pi (system)", "Arnative (Full)"] as const;
305
+ export type HeaderPreset = (typeof HEADER_PRESETS)[number];
306
+ export const DEFAULT_HEADER_PRESET: HeaderPreset = "Arnative (Full)";
307
+ const HEADER_PRESET_KEY = Symbol.for("pi-arnative.headerPreset");
308
+
309
+ export function activeHeaderPreset(): HeaderPreset {
310
+ const stored = (globalThis as Record<symbol, unknown>)[HEADER_PRESET_KEY];
311
+ return HEADER_PRESETS.includes(stored as HeaderPreset) ? (stored as HeaderPreset) : DEFAULT_HEADER_PRESET;
312
+ }
313
+
314
+ /** Hand the header slot to the active preset: arnative's box, or pi's built-in ("Pi (system)"). */
315
+ export function applyHeaderPreset(ctx: ExtensionContext): void {
316
+ if (activeHeaderPreset() === "Pi (system)") {
317
+ ctx.ui?.setHeader?.(undefined);
318
+ } else {
319
+ ctx.ui?.setHeader?.((tui, theme) => new ArnativeHeader(tui, theme, ctx));
320
+ }
321
+ }
322
+
323
+ /** Select a preset and apply it immediately (real-time: the header swaps on the spot). */
324
+ export function setHeaderPreset(name: HeaderPreset, ctx: ExtensionContext): void {
325
+ (globalThis as Record<symbol, unknown>)[HEADER_PRESET_KEY] = name;
326
+ applyHeaderPreset(ctx);
327
+ }
328
+
329
+ // The installed header instance. globalThis-backed because `/reload` re-imports the
330
+ // extension modules and arnative.ts imports this file, so two copies can coexist.
331
+ const HEADER_INSTANCE_KEY = Symbol.for("pi-arnative.headerInstance");
332
+ function headerInstance(): ArnativeHeader | null {
333
+ return (globalThis as Record<symbol, ArnativeHeader | undefined>)[HEADER_INSTANCE_KEY] ?? null;
334
+ }
335
+ function setHeaderInstance(instance: ArnativeHeader | null): void {
336
+ if (instance) (globalThis as Record<symbol, ArnativeHeader>)[HEADER_INSTANCE_KEY] = instance;
337
+ else delete (globalThis as Record<symbol, ArnativeHeader | undefined>)[HEADER_INSTANCE_KEY];
338
+ }
310
339
 
311
340
  // Model name formatted exactly like in the footer: Gemini 3.8 Flash (Antigravity), Deepseek V4.1 Flash (Yarz), etc.
312
341
  export function formatModelDisplayName(model?: { id?: string; name?: string; provider?: string }): string {
@@ -334,14 +363,14 @@ export class ArnativeHeader implements Component {
334
363
  this.tui = tui;
335
364
  this.themeProxy = themeProxy;
336
365
  this.ctx = ctx;
337
- activeHeaderInstance = this;
366
+ setHeaderInstance(this);
338
367
  }
339
368
 
340
369
  invalidate(): void {}
341
370
 
342
371
  dispose(): void {
343
- if (activeHeaderInstance === this) {
344
- activeHeaderInstance = null;
372
+ if (headerInstance() === this) {
373
+ setHeaderInstance(null);
345
374
  }
346
375
  }
347
376
 
@@ -389,13 +418,6 @@ export class ArnativeHeader implements Component {
389
418
  count: tabStore.get("Extensions")?.length ?? 0,
390
419
  label: `Extensions [${tabStore.get("Extensions")?.length ?? 0}]`,
391
420
  },
392
- {
393
- key: "Themes",
394
- name: "Themes",
395
- icon: SECTION_ICONS.Themes,
396
- count: tabStore.get("Themes")?.length ?? 0,
397
- label: `Themes [${tabStore.get("Themes")?.length ?? 0}]`,
398
- },
399
421
  {
400
422
  key: "Shortcut",
401
423
  name: "Shortcut",
@@ -443,33 +465,42 @@ export class ArnativeHeader implements Component {
443
465
  }
444
466
  const th = this.themeProxy || (activeThemeProxy as Themeish);
445
467
  const side = fgFirst(th, ["dim"], "│");
446
- const dash = "─".repeat(Math.max(0, width - 2));
447
468
 
448
- // The left column holds the logo + vertical divider, so the info area only
449
- // gets width - LOGO_W - 7 columns.
450
- const infoW = infoWidth(width);
469
+ const infoLines: string[] = [];
470
+ const brand = (name: string) => fgFirst(th, ["accent", "accentSoft"], `\x1b[1m${name}\x1b[22m`);
471
+ const dim = (text: string) => fgFirst(th, ["dim"], text);
472
+
473
+ // Left column: the mark with the pi version under it (no gap). The column is as
474
+ // wide as the widest of them.
475
+ const versionStr = VERSION || "0.87.1";
476
+ const leftLines = [
477
+ ...buildLogoLines(th),
478
+ `${brand("pi")} ${dim(`v${versionStr}`)}`,
479
+ ];
480
+ const colW = Math.max(...leftLines.map((l) => visibleWidth(l)));
481
+ const padLeft = (text: string) => text + " ".repeat(Math.max(0, colW - visibleWidth(text)));
482
+ // Columns left for info: width - "│ " - left column - " │ " - info - " │".
483
+ const infoW = Math.max(8, width - colW - 7);
451
484
  const infoLine = (text: string): string => {
452
485
  const t = truncateToWidth(text, infoW);
453
486
  return t + " ".repeat(infoW - visibleWidth(t));
454
487
  };
455
-
456
- const out: string[] = [fgFirst(th, ["dim"], `╭${dash}╮`)];
457
- const infoLines: string[] = [];
458
488
  // Inner box: the tab menu line is framed, content is (infoW - 2) columns.
459
489
  const innerW = Math.max(1, infoW - 2);
460
- const brand = (name: string) => fgFirst(th, ["accent", "tint"], `\x1b[1m${name}\x1b[22m`);
461
- const dim = (text: string) => fgFirst(th, ["dim"], text);
462
490
 
463
- // 1. Greeting: device username, accent like the brand names below it.
464
- infoLines.push(`${dim("Welcome back,")} ${brand(deviceUser())}`);
491
+ // The column divider runs the full height: it joins the top/bottom borders with
492
+ // ┬/┴ so there is no gap between the split and the frame.
493
+ const jx = colW + 3;
494
+ const border = (l: string, mid: string, r: string) =>
495
+ fgFirst(th, ["dim"], `${l}${"─".repeat(jx - 1)}${mid}${"─".repeat(Math.max(0, width - jx - 2))}${r}`);
496
+ const out: string[] = [border("╭", "┬", "╮")];
465
497
 
466
- // 2. Brand line: pi · Arnative, both in the theme accent, versions dim.
467
- const versionStr = VERSION || "0.87.1";
468
- infoLines.push(
469
- `${brand("pi")} ${dim(`v${versionStr}`)}${dim(" · ")}${brand("Arnative")}${ARNATIVE_VERSION ? ` ${dim(`v${ARNATIVE_VERSION}`)}` : ""}`,
470
- );
498
+ // 1. Brand + greeting on one line ("Arnative vX · Welcome back, <User>"; the username
499
+ // is capitalized), then a blank line before the tab box.
500
+ const arnVer = ARNATIVE_VERSION ? ` ${dim(`v${ARNATIVE_VERSION}`)}` : "";
501
+ infoLines.push(`${brand("Arnative")}${arnVer}${dim(" · ")}${dim("Welcome back,")} ${brand(capitalize(deviceUser()))}`);
471
502
 
472
- // 2. Tab menu: icon = accent, text = tint (bold when active)
503
+ // 2. Tab menu: icon = accent, text = soft (bold when active)
473
504
  const tabs = this.getTabsData(width);
474
505
  const sepTabPlain = " │ ";
475
506
  const sepTab = fgFirst(th, ["dim"], sepTabPlain);
@@ -481,7 +512,7 @@ export class ArnativeHeader implements Component {
481
512
  const isActive = t.key === this.activeTab;
482
513
  const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
483
514
  const labelStyled = isActive
484
- ? fgFirst(th, ["tint", "text"], `\x1b[1m${label}\x1b[22m`)
515
+ ? fgFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
485
516
  : fgFirst(th, ["dim"], label);
486
517
  return { key: t.key, plain, formatted: `${iconStyled} ${labelStyled}` };
487
518
  };
@@ -499,7 +530,7 @@ export class ArnativeHeader implements Component {
499
530
  }
500
531
  this.renderedTabRegions = [];
501
532
  // The tab row has its own left border + a leading space before the first tab.
502
- let curX = INFO_X + 2;
533
+ let curX = colW + 7;
503
534
  for (const t of kept) {
504
535
  const w = visibleWidth(t.plain);
505
536
  this.renderedTabRegions.push({ key: t.key, startX: curX, endX: curX + w - 1 });
@@ -512,23 +543,29 @@ export class ArnativeHeader implements Component {
512
543
  const t = truncateToWidth(text, tabInnerW);
513
544
  return `${side}${t}${" ".repeat(tabInnerW - visibleWidth(t))}${side}`;
514
545
  };
515
- const tabRule = (l: string, r: string, label = ""): string => {
516
- // Label rides on the top border ("╭─ Menu ───╮") so no extra text line is spent; the
517
- // word is tint so it reads as a label instead of a dimmer stretch of border.
518
- const lead = label.length + 1;
519
- if (label && tabInnerW - lead > 1) {
520
- return `${fgFirst(th, ["dim"], `${l}─`)}${fgFirst(th, ["tint", "text"], label)}${fgFirst(th, ["dim"], `${"─".repeat(tabInnerW - lead)}${r}`)}`;
546
+ // Columns of the " │ " separators inside the tab row, so the borders can join them
547
+ // with ┬/┴ and every divider runs the full height of the box.
548
+ const tabSeps: number[] = [];
549
+ for (let i = 0, acc = 1; i < kept.length; i++) {
550
+ acc += visibleWidth(kept[i]!.plain);
551
+ if (i < kept.length - 1) {
552
+ tabSeps.push(acc + 1);
553
+ acc += 3;
521
554
  }
522
- return fgFirst(th, ["dim"], `${l}${"─".repeat(tabInnerW)}${r}`);
555
+ }
556
+ const tabRule = (l: string, r: string, junction: string): string => {
557
+ const inner = new Array<string>(tabInnerW).fill("─");
558
+ for (const s of tabSeps) if (s >= 0 && s < tabInnerW) inner[s] = junction;
559
+ return fgFirst(th, ["dim"], `${l}${inner.join("")}${r}`);
523
560
  };
524
- infoLines.push(tabRule("╭", "╮", " Menu "));
561
+ infoLines.push(tabRule("╭", "╮", "┬"));
525
562
  // One space from the left border, then " │ " between tabs.
526
563
  infoLines.push(tabRow(tabText));
527
564
  // Tab line = top border (index 0) + the infoLines above + this line.
528
565
  this.renderedTabLineY = infoLines.length;
529
- infoLines.push(tabRule("╰", "╯"));
566
+ infoLines.push(tabRule("╰", "╯", "┴"));
530
567
 
531
- // 3. Active tab data (left-aligned, neatly wrapped, tint)
568
+ // 3. Active tab data (left-aligned, neatly wrapped, soft)
532
569
  let activeItems: string[] = [];
533
570
  if (this.activeTab === "Model") {
534
571
  const modelObj = this.ctx?.model ?? (globalThis as Record<symbol, any>)[MODEL_SNAPSHOT_KEY];
@@ -536,22 +573,22 @@ export class ArnativeHeader implements Component {
536
573
  const thLvl = this.ctx?.thinkingLevel ?? (globalThis as Record<symbol, any>)[THINKING_SNAPSHOT_KEY];
537
574
  const thinkLevel = thLvl && thLvl !== "off" ? capitalize(thLvl) : "Off";
538
575
  const sep = fgFirst(th, ["dim"], " · ");
539
- // Model name in tint, its "(Provider)" tail dim.
576
+ // Model name in soft, its "(Provider)" tail dim.
540
577
  const provSuffix = fullName.match(/(\s*\([^()]*\))$/)?.[1] ?? "";
541
578
  const nameMain = provSuffix ? fullName.slice(0, -provSuffix.length) : fullName;
542
- let line = `${fgFirst(th, ["tint", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${fgFirst(th, ["tint", "text"], `Thinking: ${thinkLevel}`)}`;
579
+ let line = `${fgFirst(th, ["accentSoft", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${fgFirst(th, ["accentSoft", "text"], `Thinking: ${thinkLevel}`)}`;
543
580
 
544
581
  // All-time usage of that model (same as /usage): ↑... ↓... ...
545
582
  const usage = getModelAllTimeUsage(modelObj?.id);
546
583
  const tokenParts: string[] = [];
547
584
  if (usage.input > 0) {
548
- tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${fgFirst(th, ["tint", "text"], formatTokens(usage.input))}`);
585
+ tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
549
586
  }
550
587
  if (usage.output > 0) {
551
- tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${fgFirst(th, ["tint", "text"], formatTokens(usage.output))}`);
588
+ tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
552
589
  }
553
590
  if (usage.cacheRead > 0) {
554
- tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${fgFirst(th, ["tint", "text"], formatTokens(usage.cacheRead))}`);
591
+ tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${fgFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
555
592
  }
556
593
  if (tokenParts.length > 0) {
557
594
  line += `${sep}${tokenParts.join(" ")}`;
@@ -564,35 +601,33 @@ export class ArnativeHeader implements Component {
564
601
 
565
602
  if (this.activeTab === "Directory") {
566
603
  for (const line of wrapPath(getCwd(this.ctx), infoW)) {
567
- infoLines.push(fgFirst(th, ["tint", "text"], line));
604
+ infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
568
605
  }
569
606
  } else if (this.activeTab === "Shortcut") {
570
607
  for (const line of wrapTokens(SHORTCUTS, infoW)) {
571
- infoLines.push(fgFirst(th, ["tint", "text"], line));
608
+ infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
572
609
  }
573
610
  } else if (activeItems.length === 0) {
574
- infoLines.push(fgFirst(th, ["tint"], "(empty)"));
611
+ infoLines.push(fgFirst(th, ["accentSoft"], "(empty)"));
575
612
  } else if (this.activeTab === "Model") {
576
- // The Model tab already carries tint styling + a dim separator
613
+ // The Model tab already carries soft styling + a dim separator
577
614
  infoLines.push(...activeItems);
578
615
  } else {
579
616
  for (const line of wrapCommaItems(activeItems, infoW)) {
580
- // Point 5: selected menu data uses tint only
581
- infoLines.push(fgFirst(th, ["tint", "text"], line));
617
+ // Point 5: selected menu data uses soft only
618
+ infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
582
619
  }
583
620
  }
584
621
 
585
- // Compose lines: logo in the left column, vertical divider, left-aligned info.
586
- // The logo is taller than the info -> the rest is left blank.
587
- const logoLines = buildLogoLines(th);
588
- const blankLogo = " ".repeat(LOGO_W);
589
- for (let i = 0; i < Math.max(logoLines.length, infoLines.length); i++) {
590
- const logo = logoLines[i] ?? blankLogo;
591
- out.push(`${side} ${logo} ${side} ${infoLine(infoLines[i] ?? "")} ${side}`);
622
+ // Compose lines: left column (mark + brand), vertical divider, left-aligned info.
623
+ // Whichever column is shorter is padded with blanks.
624
+ const rows = Math.max(leftLines.length, infoLines.length);
625
+ for (let i = 0; i < rows; i++) {
626
+ out.push(`${side} ${padLeft(leftLines[i] ?? "")} ${side} ${infoLine(infoLines[i] ?? "")} ${side}`);
592
627
  }
593
628
 
594
629
  // Bottom border
595
- out.push(fgFirst(th, ["dim"], `╰${dash}╯`));
630
+ out.push(border("╰", "┴", "╯"));
596
631
 
597
632
  return out;
598
633
  }
@@ -608,6 +643,8 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
608
643
  if (typeof containerProto?.addChild === "function") {
609
644
  const origAddChild = containerProto.addChild;
610
645
  containerProto.addChild = function (child: any): unknown {
646
+ // "Pi (system)" preset = pi's own header, so leave pi's startup list untouched.
647
+ if (activeHeaderPreset() === "Pi (system)") return origAddChild.call(this, child);
611
648
  try {
612
649
  const body = sectionBodyOf(child);
613
650
  const sectionName = body === null ? null : sectionNameOf(body);
@@ -629,8 +666,9 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
629
666
  child.render = () => [];
630
667
 
631
668
  // Re-render the header when it is already installed
632
- if (activeHeaderInstance?.tui) {
633
- activeHeaderInstance.tui.requestRender();
669
+ const hdr = headerInstance();
670
+ if (hdr?.tui) {
671
+ hdr.tui.requestRender();
634
672
  }
635
673
  } else if ((this as Record<string, unknown>)._isLoadedResourcesContainer) {
636
674
  // pi core's "[Extension issues]" -> wrap in an arnative box.
@@ -656,7 +694,7 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
656
694
  export default function (pi: ExtensionAPI) {
657
695
  // Next header tab — every platform variant of the shortcut (lib/shortcuts.ts).
658
696
  const nextTab = async () => {
659
- const hdr = activeHeaderInstance;
697
+ const hdr = headerInstance();
660
698
  if (!hdr) return;
661
699
  const order = TAB_ORDER;
662
700
  hdr.activeTab = order[(order.indexOf(hdr.activeTab) + 1) % order.length]!;
@@ -668,10 +706,8 @@ export default function (pi: ExtensionAPI) {
668
706
 
669
707
  pi.on("session_start", async (_event, ctx) => {
670
708
  activeThemeProxy = themeOf<Themeish>(ctx) ?? activeThemeProxy;
671
- // Register the custom header
672
- ctx.ui?.setHeader?.((tui, theme) => {
673
- return new ArnativeHeader(tui, theme, ctx);
674
- });
709
+ // Register the header for the active preset
710
+ applyHeaderPreset(ctx);
675
711
  });
676
712
 
677
713
  pi.on("turn_start", async (_event, ctx) => {
@@ -682,8 +718,9 @@ export default function (pi: ExtensionAPI) {
682
718
 
683
719
  pi.on("turn_end", async () => {
684
720
  // Usage is re-read at render time (collectUsageSummary caches 5s), so poking a render is enough.
685
- if (activeHeaderInstance?.tui) {
686
- activeHeaderInstance.tui.requestRender();
721
+ const hdr = headerInstance();
722
+ if (hdr?.tui) {
723
+ hdr.tui.requestRender();
687
724
  }
688
725
  });
689
726
  }
@@ -694,6 +731,17 @@ if (isMain(import.meta.url)) {
694
731
  assert(sectionNameOf("\x1b[33m[Skills]\x1b[39m\n a, b") === "Skills", "header [Skills] detected");
695
732
  assert(sectionNameOf("pi v0.87.1") === null, "non-section text is not detected");
696
733
 
734
+ // Header presets: Arnative (Full) is the default; Pi (system) hands the header slot back to pi.
735
+ assert(DEFAULT_HEADER_PRESET === "Arnative (Full)" && HEADER_PRESETS.length === 2, "Arnative (Full) is the default header preset");
736
+ assert(HEADER_PRESETS.join(" | ") === "Pi (system) | Arnative (Full)", "built-in preset is listed first");
737
+ assert(markedLabels(HEADER_PRESETS, "Arnative (Full)").join(" | ") === " Pi (system) | ● Arnative (Full)", "preset labels mark the active one");
738
+ let installed: unknown = "untouched";
739
+ const presetCtx: any = { ui: { setHeader: (factory: unknown) => { installed = factory; } } };
740
+ setHeaderPreset("Pi (system)", presetCtx);
741
+ assert(installed === undefined && activeHeaderPreset() === "Pi (system)", "Pi (system) clears the custom header");
742
+ setHeaderPreset("Arnative (Full)", presetCtx);
743
+ assert(typeof installed === "function" && activeHeaderPreset() === "Arnative (Full)", "Arnative (Full) installs the header factory");
744
+
697
745
  // Parsing & wrapping
698
746
  assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody splits 3 comma items");
699
747
  assert(extractItemsFromBody(" x\n y\n z").length === 3, "extractItemsFromBody returns 3 lines");
@@ -739,7 +787,6 @@ if (isMain(import.meta.url)) {
739
787
  tabStore.set("Context", ["AGENTS.md"]);
740
788
  tabStore.set("Skills", ["agents-sdk", "cloudflare"]);
741
789
  tabStore.set("Extensions", ["@bismawy/pi-agentrouter@1.6.1", "footer.ts"]);
742
- tabStore.set("Themes", ["arnative", "dark"]);
743
790
 
744
791
  let renderRequested = false;
745
792
  const fakeTui: any = {
@@ -760,68 +807,99 @@ if (isMain(import.meta.url)) {
760
807
 
761
808
  // Default view: the working directory path, shown on open
762
809
  const linesDefault = hdr.render(100);
763
- assert(stripAnsi(linesDefault[6]!).includes("/run/media/bisma/DATA/Pi/pi-arnative"), "directory path shown as soon as the first tab opens");
810
+ assert(stripAnsi(linesDefault[5]!).includes("/run/media/bisma/DATA/Pi/pi-arnative"), "directory path shown as soon as the first tab opens");
764
811
 
765
812
  hdr.activeTab = "Model";
766
813
  const lines100 = hdr.render(100);
767
- assert(lines100.length === 8, "render 100 = border + 6 logo lines + border (3 info lines fill 3 logo lines)");
814
+ assert(lines100.length === 7, "render 100 = border + 5 rows + border (left: mark + gap + pi version; info: brand line + tab box + data)");
768
815
  assert(lines100.every((l) => visibleWidth(l) === 100), "every line at width 100 is exactly 100 columns");
769
816
  assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "round top border");
770
- assert(lines100[7]!.includes("╰") && lines100[7]!.includes("╯"), "round bottom border");
817
+ assert(lines100[6]!.includes("╰") && lines100[6]!.includes("╯"), "round bottom border");
818
+ // The column divider joins the frame: full-height split, no gap at the borders.
819
+ const jx = stripAnsi(lines100[0]!).indexOf("┬");
820
+ assert(jx > 0 && stripAnsi(lines100[6]!).indexOf("┴") === jx, "┬/┴ junctions sit on the border at the divider column");
821
+ assert(
822
+ lines100.slice(1, 6).every((l) => stripAnsi(l)[jx] === "│"),
823
+ "the divider sits in the same column on every row",
824
+ );
771
825
  const logoPlain = buildLogoLines(null);
772
- assert(logoPlain.length === 6 && visibleWidth(logoPlain[0]!) === 13, "real Pi ascii intact (6 lines x 13 columns)");
826
+ assert(logoPlain.length === 4 && visibleWidth(logoPlain[0]!) === 8, "pi block mark intact (4 lines x 8 columns)");
827
+ // P (columns 0-2) accent, i (the right bar) soft — the old wordmark's two-tone.
828
+ const logoTagged = buildLogoLines({
829
+ fg: (c: string, t: string) => (c === "accent" ? `<A>${t}</A>` : c === "accentSoft" ? `<T>${t}</T>` : t),
830
+ } as any);
831
+ assert(logoTagged[0] === "<A>██████</A> ", "mark top row: P in the theme accent");
832
+ assert(logoTagged[2] === "<A>████</A> <T>██</T>", "mark lower rows: P accent + i (right bar) soft");
773
833
  assert(
774
- lines100.slice(1, 7).every((l, i) => stripAnsi(l).slice(2, 15) === logoPlain[i]),
775
- "real Pi ascii in the left column, 6 lines",
834
+ lines100.slice(1, 5).every((l, i) => stripAnsi(l).slice(2, 10) === logoPlain[i]),
835
+ "pi block mark in the left column, 4 lines",
776
836
  );
777
837
  assert(
778
838
  /Welcome back, \S/.test(stripAnsi(lines100[1]!)) && !stripAnsi(lines100[1]!).includes("pi v"),
779
839
  "line 1: greeting uses the device username",
780
840
  );
841
+ // Left column: the pi version sits directly under the mark (no gap).
842
+ assert(stripAnsi(lines100[5]!).slice(2, 12).includes("pi v"), "pi version sits under the mark in the left column");
843
+ // Info column: brand + greeting, then the tab box straight away (no blank line).
844
+ assert(/Arnative v\d+\.\d+\.\d+ · Welcome back, \S/.test(stripAnsi(lines100[1]!)), "info line 1: Arnative version · Welcome back, user");
845
+ assert(!stripAnsi(lines100[2]!).includes("Menu"), "tab box border carries no label");
846
+ // Tab box separators join its borders: ┬ above and ┴ below each "│". Compare by display
847
+ // column, not string index: the tab icons are astral (2 code units, 1 column).
848
+ const atColumn = (line: string, col: number): string | undefined => {
849
+ let c = 0;
850
+ for (const ch of stripAnsi(line)) {
851
+ const w = visibleWidth(ch);
852
+ if (col >= c && col < c + w) return ch;
853
+ c += w;
854
+ }
855
+ return undefined;
856
+ };
857
+ const tabSepCols = [...stripAnsi(lines100[2]!).matchAll(/┬/g)].map((m) => visibleWidth(stripAnsi(lines100[2]!).slice(0, m.index)));
858
+ assert(tabSepCols.length >= 2, "tab box top border has ┬ at each column divider");
781
859
  assert(
782
- stripAnsi(lines100[2]!).includes("pi v") && /Arnative v\d+\.\d+\.\d+/.test(stripAnsi(lines100[2]!)),
783
- "line 2: pi + Arnative + the Arnative build version",
860
+ tabSepCols.every((c) => atColumn(lines100[3]!, c) === "│" && atColumn(lines100[4]!, c) === "┴"),
861
+ "each tab divider is full height (┬ / │ / ┴ in the same column)",
784
862
  );
785
- assert(/╭─ Menu ─+╮/.test(stripAnsi(lines100[3]!)), "tab box top border carries the Menu label");
786
863
  assert(!stripAnsi(lines100[1]!).includes("Interrupt"), "shortcut legend no longer on the top line");
787
864
  // Both brand names use the theme accent, not only "pi".
788
865
  const accentTheme = {
789
866
  fg: (c: string, t: string) => (c === "accent" ? `<A>${t}</A>` : c === "dim" ? `<D>${t}</D>` : t),
790
867
  };
791
868
  const accentHeader = new ArnativeHeader(fakeTui, accentTheme as any, fakeCtx);
792
- const accentLine = accentHeader.render(100)[2]!;
793
- assert(accentLine.includes("<A>\x1b[1mpi"), "the pi name uses the theme accent");
794
- assert(accentLine.includes("<A>\x1b[1mArnative"), "the Arnative name uses the same theme accent");
795
- const greetLine = accentHeader.render(100)[1]!;
869
+ // The marker theme returns literal "<A>" text (not ANSI), so the measured width is
870
+ // inflated — render wide enough that the brand/greeting line is not truncated.
871
+ const accentLines = accentHeader.render(140);
872
+ assert(accentLines[5]!.includes("<A>\x1b[1mpi"), "the pi name uses the theme accent");
873
+ assert(accentLines[1]!.includes("<A>\x1b[1mArnative"), "the Arnative name uses the same theme accent");
874
+ const greetLine = accentHeader.render(140)[1]!;
796
875
  assert(greetLine.includes("<D>Welcome back,"), "greeting text uses the dim color");
797
- assert(greetLine.includes(`<A>\x1b[1m${deviceUser()}`), "device username uses the theme accent");
798
- assert(stripAnsi(lines100[4]!).includes("Model [Gemini 3.8 Flash]"), "Model tab in the menu (Model [name])");
876
+ assert(greetLine.includes(`<A>\x1b[1m${capitalize(deviceUser())}`), "device username uses the theme accent, first letter capitalized");
877
+ assert(stripAnsi(lines100[3]!).includes("Model [Gemini 3.8 Flash]"), "Model tab in the menu (Model [name])");
799
878
  // Directory tab: name only in the menu, whole path in the content line below
800
879
  hdr.activeTab = "Directory";
801
880
  hdr.render(100);
802
- assert(stripAnsi(lines100[4]!).includes("Directory"), "Directory tab shown in the menu");
803
- assert(!stripAnsi(lines100[4]!).includes("Directory: /"), "Directory menu entry has no path (detail only on click)");
804
- assert(stripAnsi(lines100[4]!).indexOf("Directory") < stripAnsi(lines100[4]!).indexOf("Model ["), "Directory tab is leftmost");
881
+ assert(stripAnsi(lines100[3]!).includes("Directory"), "Directory tab shown in the menu");
882
+ assert(!stripAnsi(lines100[3]!).includes("Directory: /"), "Directory menu entry has no path (detail only on click)");
883
+ assert(stripAnsi(lines100[3]!).indexOf("Directory") < stripAnsi(lines100[3]!).indexOf("Model ["), "Directory tab is leftmost");
805
884
  hdr.activeTab = "Model";
806
885
  hdr.render(100);
807
- assert(stripAnsi(lines100[6]!).includes("Gemini 3.8 Flash (Antigravity)"), "active Model tab data on the last info line");
886
+ assert(stripAnsi(lines100[5]!).includes("Gemini 3.8 Flash (Antigravity)"), "active Model tab data on the last info line");
808
887
  // Directory tab content: full path, wrapped, nothing lost
809
888
  hdr.activeTab = "Directory";
810
889
  const linesDir = hdr.render(100);
811
- const dirContent = stripAnsi(linesDir[6]!);
890
+ const dirContent = stripAnsi(linesDir[5]!);
812
891
  assert(dirContent.includes("/run/media/bisma"), "full Directory tab path (start) shown");
813
892
  assert(dirContent.includes("pi-arnative"), "full Directory tab path (end) shown — no truncation");
814
893
  hdr.activeTab = "Model";
815
894
  hdr.render(100);
816
- // Inner box rules: bottom line is one fully dimmed block (the "─" runs are colored too, not
817
- // just the corners); the top one is the same except it carries the tinted " Menu " label.
818
- assert((lines100[5]!.match(/\x1b\[/g) ?? []).length === 8, 'bottom box line (row 5) = one fully dimmed block');
895
+ // Tab box borders are a single fully dimmed block (the "─"/"┬"/"┴" runs are colored too).
896
+ assert((lines100[4]!.match(/\x1b\[/g) ?? []).length === 8, "bottom box line (row 4) = one fully dimmed block");
819
897
  assert(
820
- lines100[3]!.includes("\x1b[2m╭─\x1b[22m Menu \x1b[2m─") && lines100[3]!.includes("╮\x1b[22m"),
821
- 'tab box top border = two dim blocks with the " Menu " label between them',
898
+ lines100[2]!.includes("\x1b[2m╭─") && lines100[2]!.includes("┬") && lines100[2]!.includes("╮\x1b[22m"),
899
+ "tab box top border = one dim block carrying the ┬ junctions",
822
900
  );
823
901
 
824
- // Tab regions: Model, Context, Skills, Extensions, Themes
902
+ // Tab regions: Model, Context, Skills, Extensions
825
903
  const tabY = (hdr as any).renderedTabLineY;
826
904
  assert(tabY > 0, "tab line Y position recorded");
827
905
  const tabRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
@@ -841,7 +919,7 @@ if (isMain(import.meta.url)) {
841
919
  assert(renderRequested === true, "requestRender called when the tab switches");
842
920
 
843
921
  const linesExt = hdr.render(100);
844
- assert(stripAnsi(linesExt[6]!).includes(extRegion.key === "Extensions" ? "@bismawy/pi-agentrouter@1.6.1" : ""), "active tab data on the last info line");
922
+ assert(stripAnsi(linesExt[5]!).includes(extRegion.key === "Extensions" ? "@bismawy/pi-agentrouter@1.6.1" : ""), "active tab data on the last info line");
845
923
 
846
924
  // Click the Skills tab (measured at a width where every tab fits)
847
925
  const wide = hdr.render(140);
@@ -860,11 +938,11 @@ if (isMain(import.meta.url)) {
860
938
 
861
939
  const linesSkills = hdr.render(140);
862
940
  assert(wide.every((l) => visibleWidth(l) === 140), "lines at 140 columns stay 140");
863
- assert(wideRegions.length === 6, "width 140: first 6 tabs shown (rightmost Shortcut dropped too)");
941
+ assert(wideRegions.length === 6, "width 140: all 6 tabs shown (Shortcut included)");
864
942
  if (skillsRegion.key === "Skills") {
865
- assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "Skills tab data on the last info line");
866
- assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "Model data not shown in the Skills tab content");
867
- assert(linesSkills[4]!.includes("Model [Gemini 3.8 Flash]"), "tab menu still shown while another tab is active");
943
+ assert(linesSkills[5]!.includes("agents-sdk, cloudflare"), "Skills tab data on the last info line");
944
+ assert(!linesSkills[5]!.includes("Gemini 3.8 Flash (Antigravity)"), "Model data not shown in the Skills tab content");
945
+ assert(linesSkills[3]!.includes("Model [Gemini 3.8 Flash]"), "tab menu still shown while another tab is active");
868
946
  }
869
947
 
870
948
  // The Y of a tab must point at the tab menu line, not the line above
@@ -879,13 +957,13 @@ if (isMain(import.meta.url)) {
879
957
  ((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
880
958
  "width 170: Shortcut tab shown (not dropped)",
881
959
  );
882
- const scContent = stripAnsi(linesShortcut.slice(6).join(" "));
960
+ const scContent = stripAnsi(linesShortcut.slice(5).join(" "));
883
961
  assert(scContent.includes("[Esc] Interrupt"), "Shortcut tab: legend moved into the content");
884
962
  assert(
885
963
  scContent.includes(`[${SHORTCUT_RELOAD.label}] Reload`),
886
964
  `Shortcut tab: reload shortcut present in the content (${SHORTCUT_RELOAD.label})`,
887
965
  );
888
- assert(stripAnsi(linesShortcut[2]!).includes("Arnative v"), "line 2 keeps the Arnative brand while the Shortcut tab is active");
966
+ assert(stripAnsi(linesShortcut[1]!).includes("Arnative v"), "the Arnative brand stays on line 1 while the Shortcut tab is active");
889
967
  hdr.activeTab = "Model";
890
968
  hdr.render(140);
891
969
 
@@ -893,13 +971,13 @@ if (isMain(import.meta.url)) {
893
971
  const narrow = hdr.render(60);
894
972
  assert(narrow.every((l) => visibleWidth(l) === 60), "lines at 60 columns stay 60");
895
973
  assert(stripAnsi(narrow[1]!).includes("Welcome back,"), "greeting stays intact at 60 columns");
896
- assert(stripAnsi(narrow[2]!).includes("Arnative v"), "pi + Arnative brand intact at 60 columns");
897
- assert(stripAnsi(narrow[2]!).includes("Interrupt") === false, "legend does not come back to the top line");
974
+ assert(stripAnsi(narrow[1]!).includes("Arnative v"), "Arnative brand intact at 60 columns");
975
+ assert(stripAnsi(narrow[1]!).includes("Interrupt") === false, "legend does not come back to the top line");
898
976
  assert(
899
977
  !((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
900
978
  "rightmost tab dropped at 60 columns",
901
979
  );
902
- assert(stripAnsi(narrow[4]!).includes("Model ["), "Model tab still shown at 60 columns");
980
+ assert(stripAnsi(narrow[3]!).includes("Model ["), "Model tab still shown at 60 columns");
903
981
 
904
982
  // Intercept addChild: capture the data and suppress the child
905
983
  const proto = Object.getPrototypeOf(UserMessageComponent.prototype) as { addChild?: unknown };
@@ -917,13 +995,13 @@ if (isMain(import.meta.url)) {
917
995
  // pi 0.99 ExpandableText shape: `build` callback + `state` object, no getters.
918
996
  const modernSection = {
919
997
  state: { expanded: false },
920
- build: () => "[Themes]\n arnative-sun, arnative-dusk",
998
+ build: () => "[Context]\n AGENTS.md, MEMORY.md",
921
999
  setText: () => {},
922
1000
  render: (_w: number) => ["should be hidden"],
923
1001
  };
924
1002
  container.addChild(modernSection);
925
1003
  assert(modernSection.render(80).length === 0, "pi 0.99 section suppressed (build + state shape)");
926
- assert(tabStore.get("Themes")?.includes("arnative-sun") === true, "pi 0.99 section data lands in tabStore");
1004
+ assert(tabStore.get("Context")?.includes("MEMORY.md") === true, "pi 0.99 section data lands in tabStore");
927
1005
 
928
1006
  // Startup help block is not a resource section and must survive untouched.
929
1007
  const helpBlock = { build: () => "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", state: { expanded: false }, render: () => ["help"] };