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