@bismawy/pi-arnative 0.2.11 → 0.3.0

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