@bismawy/pi-arnative 0.2.12 → 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.
- package/CHANGELOG.md +33 -0
- package/README.md +10 -5
- package/extensions/arnative.ts +359 -0
- package/extensions/footer.ts +257 -136
- package/extensions/section-headers.ts +214 -138
- package/extensions/timestamps.ts +5 -2
- package/extensions/tools.ts +30 -40
- package/extensions/ui-render-tweaks.ts +87 -23
- package/extensions/usage.ts +37 -13
- package/lib/ansi.ts +23 -0
- package/lib/color.ts +118 -0
- package/lib/format.ts +6 -0
- package/lib/usage-store.ts +25 -16
- package/package.json +4 -3
- package/themes/arnative-cyberpunk.json +70 -78
- package/themes/arnative-dracula.json +70 -78
- package/themes/arnative-emerald.json +70 -78
- package/themes/arnative-gruvbox.json +70 -78
- package/themes/arnative-matrix.json +70 -78
- package/themes/arnative-nord.json +70 -78
- package/themes/arnative-sun.json +70 -78
- package/themes/arnative-synthwave.json +70 -78
- package/themes/arnative-violet.json +70 -78
- package/themes/arnative-zinc.json +70 -78
- package/themes/arnative.json +70 -78
- package/themes/gen-themes.mjs +301 -224
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Arnative header box & horizontal tab system:
|
|
3
|
-
* - Round-corner box
|
|
4
|
-
* divider
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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" | "
|
|
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", "
|
|
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" |
|
|
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"
|
|
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
|
|
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
|
|
274
|
-
const
|
|
275
|
-
|
|
275
|
+
const a = (s: string) => fgFirst(th, ["accent"], s);
|
|
276
|
+
const t = (s: string) => fgFirst(th, ["accentSoft", "text"], s);
|
|
276
277
|
return [
|
|
277
|
-
`${
|
|
278
|
-
`${
|
|
279
|
-
`${
|
|
280
|
-
`${
|
|
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
|
-
|
|
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
|
-
|
|
365
|
+
setHeaderInstance(this);
|
|
338
366
|
}
|
|
339
367
|
|
|
340
368
|
invalidate(): void {}
|
|
341
369
|
|
|
342
370
|
dispose(): void {
|
|
343
|
-
if (
|
|
344
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
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
|
-
//
|
|
464
|
-
|
|
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
|
-
//
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 =
|
|
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, ["
|
|
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 =
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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("╭", "╮", "
|
|
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,
|
|
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
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
607
|
+
infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
|
|
572
608
|
}
|
|
573
609
|
} else if (activeItems.length === 0) {
|
|
574
|
-
infoLines.push(fgFirst(th, ["
|
|
610
|
+
infoLines.push(fgFirst(th, ["accentSoft"], "(empty)"));
|
|
575
611
|
} else if (this.activeTab === "Model") {
|
|
576
|
-
// The Model tab already carries
|
|
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
|
|
581
|
-
infoLines.push(fgFirst(th, ["
|
|
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:
|
|
586
|
-
//
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
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(
|
|
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
|
-
|
|
633
|
-
|
|
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 =
|
|
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
|
|
672
|
-
ctx
|
|
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
|
-
|
|
686
|
-
|
|
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[
|
|
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 ===
|
|
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[
|
|
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 ===
|
|
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,
|
|
775
|
-
"
|
|
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
|
-
|
|
783
|
-
"
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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[
|
|
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[
|
|
803
|
-
assert(!stripAnsi(lines100[
|
|
804
|
-
assert(stripAnsi(lines100[
|
|
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[
|
|
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[
|
|
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
|
-
//
|
|
817
|
-
|
|
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[
|
|
821
|
-
|
|
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
|
|
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[
|
|
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:
|
|
939
|
+
assert(wideRegions.length === 6, "width 140: all 6 tabs shown (Shortcut included)");
|
|
864
940
|
if (skillsRegion.key === "Skills") {
|
|
865
|
-
assert(linesSkills[
|
|
866
|
-
assert(!linesSkills[
|
|
867
|
-
assert(linesSkills[
|
|
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(
|
|
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[
|
|
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[
|
|
897
|
-
assert(stripAnsi(narrow[
|
|
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[
|
|
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: () => "[
|
|
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("
|
|
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"] };
|