@bismawy/pi-arnative 0.3.6 → 0.3.7

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.7] - 2026-10-05
4
+
5
+ ### Fixed
6
+ - The `powershell` and `ls` tools now render in the arnative box like every other call. pi draws a tool that ships its own `renderCall`/`renderResult` but sets no `renderShell` through its own `contentBox` — a background block with no border. Both tools match that shape (their definitions spread the renderer functions onto the tool object), so they now go through `minimal()` like `bash`/`grep`/`read`. `powershell` is Windows-only: `getPowerShellConfig()` throws elsewhere, and re-registering it would activate a tool the builtin deliberately leaves off (`extensions/tools.ts`).
7
+ - pi-fff's default-mode tools `ffgrep` and `fffind` are boxed too. The box path only recognized the override names `find`/`grep`, which pi-fff uses when it replaces pi's builtins, so a session running it in default mode showed those calls as bare blocks (`extensions/tools.ts`).
8
+ - pi's `[Extension issues]` diagnostic now draws in the arnative box, stripped to two content rows: the title (warning glyph + section name, brackets dropped, `[ctrl+o to expand]` hint) over the extension's source path. pi's extra diagnostic lines are dropped — the expand hint stands in for them (`extensions/section-headers.ts`).
9
+ - The package-update box title lost the double space after its glyph (`extensions/ui-render-tweaks.ts`).
10
+
11
+ ### Changed
12
+ - The `section-headers.ts` self-check moved out of the extension into `test/section-headers.test.ts` and runs under `node --test`, so `npm test` lists it as a test instead of a script that prints `OK`. This is a pilot for the other modules; the remaining in-file self-checks follow the same layout once proven (`extensions/section-headers.ts`, `test/section-headers.test.ts`, `package.json`).
13
+
3
14
  ## [0.3.6] - 2026-10-05
4
15
 
5
16
  ### Fixed
@@ -11,9 +11,9 @@
11
11
  * - Box divider '├────┤'. Active tab content is left-aligned, wrapped, soft.
12
12
  * - Hides pi's built-in stacked list in loadedResourcesContainer.
13
13
  */
14
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
14
+ import { readFileSync, readdirSync } from "node:fs";
15
15
  import { fileURLToPath } from "node:url";
16
- import { tmpdir, userInfo } from "node:os";
16
+ import { userInfo } from "node:os";
17
17
  import { join } from "node:path";
18
18
  import {
19
19
  UserMessageComponent,
@@ -30,12 +30,12 @@ import {
30
30
  type TuiMouseEvent,
31
31
  type TuiMouseEventResult,
32
32
  } from "@earendil-works/pi-tui";
33
+ import type { BoxTheme } from "../lib/box.ts";
33
34
  import { renderBoxLines } from "../lib/box.ts";
34
35
  import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
35
- import { assert, isMain } from "../lib/check.ts";
36
- import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
36
+ import { capitalize, expandKeyName, modelDisplayParts } from "../lib/format.ts";
37
37
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
38
- import { HEADER_PRESET_KEY as HEADER_PRESET_CONFIG_KEY, loadChoice, resetPresetCache, saveChoice } from "../lib/preset-store.ts";
38
+ import { HEADER_PRESET_KEY as HEADER_PRESET_CONFIG_KEY, loadChoice, saveChoice } from "../lib/preset-store.ts";
39
39
  import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
40
40
 
41
41
  export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Shortcut";
@@ -52,6 +52,9 @@ export const SECTION_ICONS: Record<TabKey, string> = {
52
52
  Shortcut: "\uf11c", // nf-fa-keyboard_o
53
53
  };
54
54
 
55
+ // nf-cod-warning, width 1 — prefixes a pi diagnostic block's compact header.
56
+ const DIAGNOSTIC_ICON = "\uea6c";
57
+
55
58
  export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
56
59
 
57
60
  const MODEL_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentModel");
@@ -644,6 +647,30 @@ export class ArnativeHeader implements Component {
644
647
  }
645
648
  }
646
649
 
650
+ // Arnative box for pi's `[Extension issues]` diagnostic: exactly two rows, the title
651
+ // (warning icon + section name, brackets dropped, + expand hint) over the extension's
652
+ // source path. pi's extra diagnostic lines are dropped — the expand hint stands in for
653
+ // them. Uses only `warning` + `dim`, both defined by every pi theme, so it draws the
654
+ // same under any theme; `rows` is pi's own render output, row 0 being the title.
655
+ export function extensionIssuesLines(th: BoxTheme | null, width: number, rows: string[], hint: string): string[] {
656
+ const paint = (color: string, text: string) => {
657
+ try {
658
+ return th?.fg ? th.fg(color, text) : text;
659
+ } catch {
660
+ return text;
661
+ }
662
+ };
663
+ // Rows are plain text: ANSI inside would break the width math of renderBoxLines.
664
+ const title = stripAnsi(rows[0] ?? "").trim().replace(/^\[(.+)\]$/, "$1");
665
+ const hintPlain = stripAnsi(hint).trim();
666
+ const detail = stripAnsi(rows[1] ?? "").trim();
667
+ const header = `${paint("warning", `${DIAGNOSTIC_ICON} ${title}`)}${hintPlain ? ` ${paint("dim", hintPlain)}` : ""}`;
668
+ return renderBoxLines(th, width, [header, detail], undefined, "warning");
669
+ }
670
+
671
+ // Hooks the shared `app.tools.expand` key: `ctrl+o to expand` -> `[ctrl+o to expand]`.
672
+ const wrapHint = (key: string): string => `[${key} to expand]`;
673
+
647
674
  // Intercept loadedResourcesContainer: capture data for the tabs and hide pi's own display
648
675
  const SECTION_HEADER_KEY = Symbol.for("pi-arnative.sectionHeadersRewritten");
649
676
  if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[SECTION_HEADER_KEY]) {
@@ -687,7 +714,11 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
687
714
  const text = themedTextOf(child);
688
715
  if (text.includes("[Extension issues]") && typeof child.render === "function") {
689
716
  const origRender = child.render.bind(child);
690
- child.render = (w: number) => renderBoxLines(getActiveTheme<Themeish>(), w, origRender(w), undefined, "warning");
717
+ // Shortcut hint only when the shortcut registry is reachable; otherwise just the
718
+ // two-line compact block.
719
+ const rawHint = wrapHint(expandKeyName());
720
+ child.render = (w: number) =>
721
+ extensionIssuesLines(getActiveTheme<BoxTheme>(), w, origRender(w), rawHint);
691
722
  return origAddChild.call(this, child);
692
723
  }
693
724
  // Any other element inside loadedResourcesContainer (e.g. Spacer) is hidden too
@@ -748,346 +779,3 @@ export default function (pi: ExtensionAPI) {
748
779
  }
749
780
  });
750
781
  }
751
-
752
- // Self-check: `node extensions/section-headers.ts`
753
- if (isMain(import.meta.url)) {
754
-
755
- assert(sectionNameOf("\x1b[33m[Skills]\x1b[39m\n a, b") === "Skills", "header [Skills] detected");
756
- assert(sectionNameOf("pi v0.87.1") === null, "non-section text is not detected");
757
-
758
- // Header presets: Arnative (Full) is the default; Pi (system) hands the header slot back to pi.
759
- assert(DEFAULT_HEADER_PRESET === "Arnative (Full)" && HEADER_PRESETS.length === 2, "Arnative (Full) is the default header preset");
760
- assert(HEADER_PRESETS.join(" | ") === "Pi (system) | Arnative (Full)", "built-in preset is listed first");
761
- assert(markedLabels(HEADER_PRESETS, "Arnative (Full)").join(" | ") === " Pi (system) | ● Arnative (Full)", "preset labels mark the active one");
762
- // Persisting a preset writes a config file: point it at a fixture so the real agent dir stays clean.
763
- const presetFixture = mkdtempSync(join(tmpdir(), "arnative-hdr-"));
764
- const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
765
- process.env.PI_CODING_AGENT_DIR = presetFixture;
766
- resetPresetCache();
767
- let installed: unknown = "untouched";
768
- const presetCtx: any = { ui: { setHeader: (factory: unknown) => { installed = factory; } } };
769
- setHeaderPreset("Pi (system)", presetCtx);
770
- assert(installed === undefined && activeHeaderPreset() === "Pi (system)", "Pi (system) clears the custom header");
771
- setHeaderPreset("Arnative (Full)", presetCtx);
772
- assert(typeof installed === "function" && activeHeaderPreset() === "Arnative (Full)", "Arnative (Full) installs the header factory");
773
- // Persist a NON-default value: asserting the default survives proves nothing.
774
- setHeaderPreset("Pi (system)", presetCtx);
775
- assert(loadChoice(HEADER_PRESET_CONFIG_KEY, HEADER_PRESETS) === "Pi (system)", "the header preset is persisted to disk");
776
- delete (globalThis as Record<symbol, unknown>)[HEADER_PRESET_KEY];
777
- resetPresetCache();
778
- assert(activeHeaderPreset() === "Pi (system)", "a restart reads the non-default preset back from disk");
779
- rmSync(presetFixture, { recursive: true, force: true });
780
- if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
781
- else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
782
- resetPresetCache();
783
-
784
- // Parsing & wrapping
785
- assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody splits 3 comma items");
786
- assert(extractItemsFromBody(" x\n y\n z").length === 3, "extractItemsFromBody returns 3 lines");
787
-
788
- // Directory path wrapping: never truncated, always the full path
789
- const wp = wrapPath("/run/media/bisma/DATA/Pi/pi-arnative", 20);
790
- assert(wp.join("") === "/run/media/bisma/DATA/Pi/pi-arnative", "wrapPath drops no segment");
791
- assert(wp.every((l) => visibleWidth(l) <= 20), "wrapPath respects the width");
792
- assert(wrapPath("D:\\Pi\\pi-arnative", 80).join("") === "D:/Pi/pi-arnative", "wrapPath normalizes backslashes");
793
- assert(wrapPath("/a/very-long-segment-name", 6).every((l) => visibleWidth(l) <= 6), "long segment is sliced");
794
-
795
- const wrapTest = wrapCommaItems(["item1", "item2", "item3", "item4"], 15);
796
- assert(wrapTest.length >= 2, "wrapCommaItems wraps into lines");
797
- assert(visibleWidth(wrapTest[0]!) <= 15, "wrapped line width within the limit");
798
-
799
- const cLine = centerLine("test", 20);
800
- assert(visibleWidth(cLine) === 20, "centerLine is exactly 20 columns");
801
- assert(cLine.startsWith("│") && cLine.endsWith("│"), "centerLine starts and ends with │");
802
-
803
- // Model name formatting
804
- assert(
805
- formatModelDisplayName({ name: "Gemini 3.8 Flash", provider: "antigravity" }) ===
806
- "Gemini 3.8 Flash (Antigravity)",
807
- "formatModelDisplayName with name and provider",
808
- );
809
- assert(
810
- formatModelDisplayName({ id: "deepseek-v4.1-flash", provider: "yarz" }) ===
811
- "Deepseek V4.1 Flash (Yarz)",
812
- "formatModelDisplayName converts id to titlecase",
813
- );
814
-
815
- // Package & version resolver
816
- assert(packageNameOf("@bismawy/pi-agentrouter") === "@bismawy/pi-agentrouter", "plain npm label");
817
- assert(packageNameOf("footer.ts") === "footer.ts", "local label");
818
-
819
- const gitTmp = mkdtempSync(join(tmpdir(), "pi-ver-"));
820
- mkdirSync(join(gitTmp, "git", "github.com", "test/arnative"), { recursive: true });
821
- writeFileSync(join(gitTmp, "git", "github.com", "test/arnative", "package.json"), JSON.stringify({ version: "9.9.9" }));
822
- assert(installedVersion("test/arnative", join(gitTmp, "npm"), join(gitTmp, "git")) === "9.9.9", "version from the git clone");
823
- rmSync(gitTmp, { recursive: true, force: true });
824
-
825
- // ArnativeHeader component & mouse tab click
826
- tabStore.set("Context", ["AGENTS.md"]);
827
- tabStore.set("Skills", ["agents-sdk", "cloudflare"]);
828
- tabStore.set("Extensions", ["@bismawy/pi-agentrouter@1.6.1", "footer.ts"]);
829
-
830
- let renderRequested = false;
831
- const fakeTui: any = {
832
- requestRender: () => {
833
- renderRequested = true;
834
- },
835
- };
836
- const fakeCtx: any = {
837
- model: { name: "Gemini 3.8 Flash", provider: "antigravity" },
838
- thinkingLevel: "low",
839
- cwd: "/run/media/bisma/DATA/Pi/pi-arnative",
840
- };
841
-
842
- // Only "dim" is colored, so the box line colors can be asserted.
843
- const ansiTheme = { fg: (c: string, t: string) => (c === "dim" ? `\x1b[2m${t}\x1b[22m` : t) };
844
-
845
- // Counts come from pi's loaded-resource listing. With `quietStartup: true | "header"`
846
- // pi skips that listing, so the counts are unknown — shown as "—", never a misleading 0.
847
- resourcesListingShown(false);
848
- tabStore.set("Context", []);
849
- tabStore.set("Skills", []);
850
- tabStore.set("Extensions", []);
851
- const hdrQuiet = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
852
- assert(hdrQuiet.getTabsData(120).map((t) => t.label).join("|").includes("Skills [—]"), "hidden listing: counts render as —, not 0");
853
- hdrQuiet.activeTab = "Skills";
854
- assert(
855
- hdrQuiet.render(120).some((l) => stripAnsi(l).includes("(not tracked")),
856
- "hidden listing: empty tab explains itself instead of a bare (empty)",
857
- );
858
- resourcesListingShown(true);
859
- assert(new ArnativeHeader(fakeTui, ansiTheme, fakeCtx).getTabsData(120).some((t) => t.label === "Skills [0]"), "shown listing: 0 is a real count again");
860
- tabStore.set("Context", ["AGENTS.md"]);
861
- tabStore.set("Skills", ["agents-sdk", "cloudflare"]);
862
- tabStore.set("Extensions", ["@bismawy/pi-agentrouter@1.6.1", "footer.ts"]);
863
- const hdr = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
864
- assert(hdr.activeTab === "Directory", "default tab is Directory");
865
-
866
- // Default view: the working directory path, shown on open
867
- const linesDefault = hdr.render(100);
868
- assert(stripAnsi(linesDefault[5]!).includes("/run/media/bisma/DATA/Pi/pi-arnative"), "directory path shown as soon as the first tab opens");
869
-
870
- hdr.activeTab = "Model";
871
- const lines100 = hdr.render(100);
872
- assert(lines100.length === 7, "render 100 = border + 5 rows + border (left: mark + gap + pi version; info: brand line + tab box + data)");
873
- assert(lines100.every((l) => visibleWidth(l) === 100), "every line at width 100 is exactly 100 columns");
874
- assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "round top border");
875
- assert(lines100[6]!.includes("╰") && lines100[6]!.includes("╯"), "round bottom border");
876
- // The column divider joins the frame: full-height split, no gap at the borders.
877
- const jx = stripAnsi(lines100[0]!).indexOf("┬");
878
- assert(jx > 0 && stripAnsi(lines100[6]!).indexOf("┴") === jx, "┬/┴ junctions sit on the border at the divider column");
879
- assert(
880
- lines100.slice(1, 6).every((l) => stripAnsi(l)[jx] === "│"),
881
- "the divider sits in the same column on every row",
882
- );
883
- const logoPlain = buildLogoLines(null);
884
- assert(logoPlain.length === 4 && visibleWidth(logoPlain[0]!) === 8, "pi block mark intact (4 lines x 8 columns)");
885
- // P (columns 0-2) accent, i (the right bar) soft — the old wordmark's two-tone.
886
- const logoTagged = buildLogoLines({
887
- fg: (c: string, t: string) => (c === "accent" ? `<A>${t}</A>` : c === "accentSoft" ? `<T>${t}</T>` : t),
888
- } as any);
889
- assert(logoTagged[0] === "<A>██████</A> ", "mark top row: P in the theme accent");
890
- assert(logoTagged[2] === "<A>████</A> <T>██</T>", "mark lower rows: P accent + i (right bar) soft");
891
- assert(
892
- lines100.slice(1, 5).every((l, i) => stripAnsi(l).slice(2, 10) === logoPlain[i]),
893
- "pi block mark in the left column, 4 lines",
894
- );
895
- assert(
896
- /Welcome back, \S/.test(stripAnsi(lines100[1]!)) && !stripAnsi(lines100[1]!).includes("pi v"),
897
- "line 1: greeting uses the device username",
898
- );
899
- // Left column: the pi version sits directly under the mark (no gap).
900
- assert(stripAnsi(lines100[5]!).slice(2, 12).includes("pi v"), "pi version sits under the mark in the left column");
901
- // Info column: brand + greeting, then the tab box straight away (no blank line).
902
- assert(/Arnative v\d+\.\d+\.\d+ · Welcome back, \S/.test(stripAnsi(lines100[1]!)), "info line 1: Arnative version · Welcome back, user");
903
- assert(!stripAnsi(lines100[2]!).includes("Menu"), "tab box border carries no label");
904
- // Tab box separators join its borders: ┬ above and ┴ below each "│". Compare by display
905
- // column, not string index: the tab icons are astral (2 code units, 1 column).
906
- const atColumn = (line: string, col: number): string | undefined => {
907
- let c = 0;
908
- for (const ch of stripAnsi(line)) {
909
- const w = visibleWidth(ch);
910
- if (col >= c && col < c + w) return ch;
911
- c += w;
912
- }
913
- return undefined;
914
- };
915
- const tabSepCols = [...stripAnsi(lines100[2]!).matchAll(/┬/g)].map((m) => visibleWidth(stripAnsi(lines100[2]!).slice(0, m.index)));
916
- assert(tabSepCols.length >= 2, "tab box top border has ┬ at each column divider");
917
- assert(
918
- tabSepCols.every((c) => atColumn(lines100[3]!, c) === "│" && atColumn(lines100[4]!, c) === "┴"),
919
- "each tab divider is full height (┬ / │ / ┴ in the same column)",
920
- );
921
- assert(!stripAnsi(lines100[1]!).includes("Interrupt"), "shortcut legend no longer on the top line");
922
- // Both brand names use the theme accent, not only "pi".
923
- const accentTheme = {
924
- fg: (c: string, t: string) => (c === "accent" ? `<A>${t}</A>` : c === "dim" ? `<D>${t}</D>` : t),
925
- };
926
- const accentHeader = new ArnativeHeader(fakeTui, accentTheme as any, fakeCtx);
927
- // The marker theme returns literal "<A>" text (not ANSI), so the measured width is
928
- // inflated — render wide enough that the brand/greeting line is not truncated.
929
- const accentLines = accentHeader.render(140);
930
- assert(accentLines[5]!.includes("<A>\x1b[1mpi"), "the pi name uses the theme accent");
931
- assert(accentLines[1]!.includes("<A>\x1b[1mArnative"), "the Arnative name uses the same theme accent");
932
- const greetLine = accentHeader.render(140)[1]!;
933
- assert(greetLine.includes("<D>Welcome back,"), "greeting text uses the dim color");
934
- assert(greetLine.includes(`<A>\x1b[1m${capitalize(deviceUser())}`), "device username uses the theme accent, first letter capitalized");
935
- assert(stripAnsi(lines100[3]!).includes("Model [Gemini 3.8 Flash]"), "Model tab in the menu (Model [name])");
936
- // Directory tab: name only in the menu, whole path in the content line below
937
- hdr.activeTab = "Directory";
938
- hdr.render(100);
939
- assert(stripAnsi(lines100[3]!).includes("Directory"), "Directory tab shown in the menu");
940
- assert(!stripAnsi(lines100[3]!).includes("Directory: /"), "Directory menu entry has no path (detail only on click)");
941
- assert(stripAnsi(lines100[3]!).indexOf("Directory") < stripAnsi(lines100[3]!).indexOf("Model ["), "Directory tab is leftmost");
942
- hdr.activeTab = "Model";
943
- hdr.render(100);
944
- assert(stripAnsi(lines100[5]!).includes("Gemini 3.8 Flash (Antigravity)"), "active Model tab data on the last info line");
945
- // Directory tab content: full path, wrapped, nothing lost
946
- hdr.activeTab = "Directory";
947
- const linesDir = hdr.render(100);
948
- const dirContent = stripAnsi(linesDir[5]!);
949
- assert(dirContent.includes("/run/media/bisma"), "full Directory tab path (start) shown");
950
- assert(dirContent.includes("pi-arnative"), "full Directory tab path (end) shown — no truncation");
951
- hdr.activeTab = "Model";
952
- hdr.render(100);
953
- // Tab box borders are a single fully dimmed block (the "─"/"┬"/"┴" runs are colored too).
954
- assert((lines100[4]!.match(/\x1b\[/g) ?? []).length === 8, "bottom box line (row 4) = one fully dimmed block");
955
- assert(
956
- lines100[2]!.includes("\x1b[2m╭─") && lines100[2]!.includes("┬") && lines100[2]!.includes("╮\x1b[22m"),
957
- "tab box top border = one dim block carrying the ┬ junctions",
958
- );
959
-
960
- // Tab regions: Model, Context, Skills, Extensions
961
- const tabY = (hdr as any).renderedTabLineY;
962
- assert(tabY > 0, "tab line Y position recorded");
963
- const tabRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
964
- assert(tabRegions.length >= 3, "tab regions filled (right tabs may be cut at 100 columns)");
965
- assert(tabRegions.some((r) => r.key === "Directory"), "Directory tab present in the menu (width 100)");
966
-
967
- // Click the Extensions tab (when truncated at 100 columns, use the rightmost tab)
968
- const extRegion = tabRegions.find((r) => r.key === "Extensions") ?? tabRegions[tabRegions.length - 1]!;
969
- const clickExt = hdr.handleMouse({
970
- type: "click",
971
- button: "left",
972
- x: Math.floor((extRegion.startX + extRegion.endX) / 2),
973
- y: tabY,
974
- } as any);
975
- assert(clickExt?.handled === true, "mouse click on the tab is handled");
976
- assert(hdr.activeTab === extRegion.key, "activeTab switches to the clicked tab");
977
- assert(renderRequested === true, "requestRender called when the tab switches");
978
-
979
- const linesExt = hdr.render(100);
980
- assert(stripAnsi(linesExt[5]!).includes(extRegion.key === "Extensions" ? "@bismawy/pi-agentrouter@1.6.1" : ""), "active tab data on the last info line");
981
-
982
- // Click the Skills tab (measured at a width where every tab fits)
983
- const wide = hdr.render(140);
984
- const wideRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
985
- const skillsRegion = wideRegions.find((r) => r.key === "Skills") ?? wideRegions[wideRegions.length - 1]!;
986
- hdr.activeTab = "Model";
987
- hdr.render(140);
988
- const skillsY = (hdr as any).renderedTabLineY;
989
- hdr.handleMouse({
990
- type: "click",
991
- button: "left",
992
- x: Math.floor((skillsRegion.startX + skillsRegion.endX) / 2),
993
- y: skillsY,
994
- } as any);
995
- assert(hdr.activeTab === skillsRegion.key, `activeTab switches to ${skillsRegion.key} after the click`);
996
-
997
- const linesSkills = hdr.render(140);
998
- assert(wide.every((l) => visibleWidth(l) === 140), "lines at 140 columns stay 140");
999
- assert(wideRegions.length === 6, "width 140: all 6 tabs shown (Shortcut included)");
1000
- if (skillsRegion.key === "Skills") {
1001
- assert(linesSkills[5]!.includes("agents-sdk, cloudflare"), "Skills tab data on the last info line");
1002
- assert(!linesSkills[5]!.includes("Gemini 3.8 Flash (Antigravity)"), "Model data not shown in the Skills tab content");
1003
- assert(linesSkills[3]!.includes("Model [Gemini 3.8 Flash]"), "tab menu still shown while another tab is active");
1004
- }
1005
-
1006
- // The Y of a tab must point at the tab menu line, not the line above
1007
- const rowsPlain = lines100.map((l) => stripAnsi(l));
1008
- assert(rowsPlain[tabY]!.includes("Model ["), "renderedTabLineY points at the tab menu line");
1009
- assert(tabRegions.every((r) => rowsPlain[tabY]!.slice(r.startX, r.endX + 1).includes(r.key === "Model" ? "Model [" : r.key)), "tab regions align with the text on that line");
1010
-
1011
- // Shortcut tab: the key cheatsheet lives in the tab content, not in a header line
1012
- hdr.activeTab = "Shortcut";
1013
- const linesShortcut = hdr.render(170);
1014
- assert(
1015
- ((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
1016
- "width 170: Shortcut tab shown (not dropped)",
1017
- );
1018
- const scContent = stripAnsi(linesShortcut.slice(5).join(" "));
1019
- assert(scContent.includes("[Esc] Interrupt"), "Shortcut tab: legend moved into the content");
1020
- assert(
1021
- scContent.includes(`[${SHORTCUT_RELOAD.label}] Reload`),
1022
- `Shortcut tab: reload shortcut present in the content (${SHORTCUT_RELOAD.label})`,
1023
- );
1024
- assert(stripAnsi(linesShortcut[1]!).includes("Arnative v"), "the Arnative brand stays on line 1 while the Shortcut tab is active");
1025
- hdr.activeTab = "Model";
1026
- hdr.render(140);
1027
-
1028
- // Narrow screen: brand lines + tabs + data stay intact, rightmost tabs are dropped
1029
- const narrow = hdr.render(60);
1030
- assert(narrow.every((l) => visibleWidth(l) === 60), "lines at 60 columns stay 60");
1031
- assert(stripAnsi(narrow[1]!).includes("Welcome back,"), "greeting stays intact at 60 columns");
1032
- assert(stripAnsi(narrow[1]!).includes("Arnative v"), "Arnative brand intact at 60 columns");
1033
- assert(stripAnsi(narrow[1]!).includes("Interrupt") === false, "legend does not come back to the top line");
1034
- assert(
1035
- !((hdr as any).renderedTabRegions as Array<{ key: TabKey }>).some((r) => r.key === "Shortcut"),
1036
- "rightmost tab dropped at 60 columns",
1037
- );
1038
- assert(stripAnsi(narrow[3]!).includes("Model ["), "Model tab still shown at 60 columns");
1039
-
1040
- // Intercept addChild: capture the data and suppress the child
1041
- const proto = Object.getPrototypeOf(UserMessageComponent.prototype) as { addChild?: unknown };
1042
- const container: any = { children: [] as any[], addChild: proto.addChild as (c: any) => unknown };
1043
- const fakeSection = {
1044
- getCollapsedText: () => "[Skills]\n new-skill-1, new-skill-2",
1045
- getExpandedText: () => "[Skills]\n new-skill-1, new-skill-2",
1046
- setText: () => {},
1047
- render: (_w: number) => ["should be hidden"],
1048
- };
1049
- container.addChild(fakeSection);
1050
- assert(fakeSection.render(80).length === 0, "child of loadedResourcesContainer suppressed (render = [])");
1051
- assert(tabStore.get("Skills")?.includes("new-skill-1") === true, "new data lands in tabStore");
1052
-
1053
- // pi 0.99 ExpandableText shape: `build` callback + `state` object, no getters.
1054
- const modernSection = {
1055
- state: { expanded: false },
1056
- build: () => "[Context]\n AGENTS.md, MEMORY.md",
1057
- setText: () => {},
1058
- render: (_w: number) => ["should be hidden"],
1059
- };
1060
- container.addChild(modernSection);
1061
- assert(modernSection.render(80).length === 0, "pi 0.99 section suppressed (build + state shape)");
1062
- assert(tabStore.get("Context")?.includes("MEMORY.md") === true, "pi 0.99 section data lands in tabStore");
1063
-
1064
- // Startup help block is not a resource section and must survive untouched.
1065
- const helpBlock = { build: () => "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", state: { expanded: false }, render: () => ["help"] };
1066
- container.addChild(helpBlock);
1067
- assert(sectionBodyOf(helpBlock) === "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", "sectionBodyOf reads the build shape");
1068
- assert(sectionNameOf(sectionBodyOf(helpBlock)!) === null, "startup help block is not a resource section");
1069
-
1070
- // pi's "[Extension issues]" diagnostic renders while the installing module's theme is
1071
- // still null (a `/reload` copy never saw session_start). The box must draw uncolored,
1072
- // not deref null — that crash was reported in the wild.
1073
- setActiveTheme(null);
1074
- const issuesChild = {
1075
- build: () => "[Extension issues]\n extension \"x\" failed",
1076
- render: (_w: number) => ["[Extension issues]", ' extension "x" failed'],
1077
- };
1078
- container.addChild(issuesChild);
1079
- const issuesLines = issuesChild.render(80);
1080
- assert(issuesLines[0]!.startsWith("\u256d") && issuesLines.at(-1)!.startsWith("\u2570"), "extension-issues box renders without a theme");
1081
-
1082
- // The theme proxy is globalThis-backed, so the copy that owns the addChild patch still
1083
- // paints once the other copy's session_start has stored the theme.
1084
- setActiveTheme({ fg: (c: string, t: string) => `<${c}>${t}</${c}>` });
1085
- const coloredChild = {
1086
- build: () => "[Extension issues]\n extension \"x\" failed",
1087
- render: (_w: number) => ["[Extension issues]", ' extension "x" failed'],
1088
- };
1089
- container.addChild(coloredChild);
1090
- assert(coloredChild.render(80)[0]!.includes("<warning>"), "shared theme proxy colors the box even in the patched copy");
1091
-
1092
- console.log("section-headers.ts self-check OK");
1093
- }
@@ -24,6 +24,10 @@ import {
24
24
  createEditTool,
25
25
  createFindTool,
26
26
  createGrepTool,
27
+ createLsTool,
28
+ createLsToolDefinition,
29
+ createPowerShellTool,
30
+ createPowerShellToolDefinition,
27
31
  createReadTool,
28
32
  createWriteTool,
29
33
  getMarkdownTheme,
@@ -290,11 +294,23 @@ for (const n of BOXED_TOOLS.keys()) OWN_BOX.add(n);
290
294
 
291
295
  const MCP_TOOL = (name: string): boolean => name === "mcp" || name === "mcpScript" || name.startsWith("mcp__");
292
296
 
293
- const FFF_OVERRIDE = ["find", "grep"];
294
- const usesTheirCallRenderer = (self: any): boolean =>
295
- BOXED_TOOLS.has(self.toolName) ||
296
- MCP_TOOL(self.toolName) ||
297
- (FFF_OVERRIDE.includes(self.toolName) && self.toolDefinition?.renderShell !== "self");
297
+ // pi-fff / pi core/plugin tools whose renderers we rebuild in our own box when their
298
+ // renderer carries no explicit `renderShell` marker (our `minimal()` sets "self").
299
+ // pi-fff: `ffgrep`/`fffind` in the default mode, `find`/`grep` once it "overrides"
300
+ // pi's builtins; matching either name covers both of its modes.
301
+ // pi core: `powershell` and `ls` — pi's runtime keeps `createAllToolDefinitions`, which
302
+ // spreads `renderCall`/`renderResult` onto the tool OBJECT; `wrapToolDefinition`
303
+ // (used by `createPowerShellTool`/`createLsTool`) drops them instead.
304
+ const boxTheirRenderers = (self: any): boolean =>
305
+ self.toolDefinition?.renderShell !== "self" &&
306
+ (self.toolName === "ls" ||
307
+ self.toolName === "powershell" ||
308
+ self.toolName === "ffgrep" ||
309
+ self.toolName === "fffind" ||
310
+ self.toolName === "find" ||
311
+ self.toolName === "grep" ||
312
+ BOXED_TOOLS.has(self.toolName) ||
313
+ MCP_TOOL(self.toolName));
298
314
 
299
315
  // The MCP box title names the invoked tool (full args when expanded).
300
316
  const mcpInfo = (name: string, args: any, th: Theme): string => {
@@ -352,7 +368,11 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
352
368
  const origCall = proto.getCallRenderer;
353
369
  const origResult = proto.getResultRenderer;
354
370
  const origShell = proto.getRenderShell;
355
- const hasOwnRenderer = (self: any): boolean => Boolean(origCall.call(self) || origResult.call(self));
371
+ const hasOwnRenderer = (self: any): boolean =>
372
+ !boxTheirRenderers(self) && Boolean(origCall.call(self) || origResult.call(self));
373
+ // pi-fff splits title (renderCall) and result (renderResult) — both merge into one box.
374
+ // Its two naming modes are covered: `find`/`grep` (override) and `ffgrep`/`fffind` (default).
375
+ const FFF_TOOLS = ["find", "grep", "ffgrep", "fffind"];
356
376
 
357
377
  // A tool call with no registered definition (e.g. an unknown tool name that
358
378
  // errored with "Tool X not found") makes hasRendererDefinition() false, so the
@@ -366,13 +386,13 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
366
386
  };
367
387
 
368
388
  proto.getRenderShell = function (): string {
369
- if (usesTheirCallRenderer(this)) return "self";
389
+ if (boxTheirRenderers(this)) return "self";
370
390
  return hasOwnRenderer(this) ? origShell.call(this) : "self";
371
391
  };
372
392
 
373
393
  proto.getCallRenderer = function () {
374
394
  const name: string = this.toolName;
375
- const mine = usesTheirCallRenderer(this);
395
+ const mine = boxTheirRenderers(this);
376
396
  const own = origCall.call(this);
377
397
  // Chrome DevTools' own renderCall is a bare line; ours replaces it (icon + name).
378
398
  if (own && !mine && !CDP_NAME.has(name)) return own;
@@ -394,9 +414,9 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
394
414
  const name: string = this.toolName;
395
415
  const own = origResult.call(this);
396
416
  // pi-fff splits title (renderCall) and result (renderResult); both go in one box.
397
- const fff = FFF_OVERRIDE.includes(name) && this.toolDefinition?.renderShell !== "self";
417
+ const fff = FFF_TOOLS.includes(name) && this.toolDefinition?.renderShell !== "self";
398
418
  // MCP: their result renderer is deliberately skipped (unbounded error dump).
399
- if (own && !usesTheirCallRenderer(this)) return own;
419
+ if (own && !boxTheirRenderers(this)) return own;
400
420
  const boxed = BOXED_TOOLS.has(name);
401
421
  // Chrome DevTools are in BOXED_TOOLS for the title/icon only: their renderResult
402
422
  // (raw text, empty when collapsed) would drop our title row, so it is ignored.
@@ -660,6 +680,31 @@ function arnativeTools(pi: ExtensionAPI) {
660
680
 
661
681
  // Gap is only stripped for our own boxes (see OWN_BOX above).
662
682
 
683
+ // powershell: pi core registers it as a separate builtin (only on Windows); without
684
+ // our own registration it keeps pi's "default" shell and renders as a bg block, not a
685
+ // box (the same reason `bash` is re-registered below). Windows only: on any other
686
+ // platform `getPowerShellConfig()` throws, and an unregistered tool would stay off
687
+ // while our registration is active on load.
688
+ if (process.platform === "win32")
689
+ minimal(
690
+ pi,
691
+ createPowerShellTool(cwd),
692
+ (th) => th.fg("accent", "PS>"),
693
+ (a, _th, soft, expanded) => {
694
+ const cmd = (a.command ?? "").replace(/\r?\n/g, " ").trim();
695
+ return paintLinks(expanded ? cmd : smartTitle(cmd), soft);
696
+ },
697
+ );
698
+
699
+ // ls: no renderShell of its own, so pi's default shell turns it into a bg block.
700
+ minimal(
701
+ pi,
702
+ createLsTool(cwd),
703
+ (th) => th.fg("accent", "ls"),
704
+ (a, _th, soft) => soft(shortPath(a.path ?? ".")),
705
+ numRes((n) => `${n} entries`),
706
+ );
707
+
663
708
  // bash: `󰔟 $ <6 words>…` / `✓ $ <full title when expanded> 0.1s`
664
709
  minimal(
665
710
  pi,
@@ -997,6 +1042,38 @@ if (isMain(import.meta.url)) {
997
1042
  assert(fffOut.some((l: string) => l.includes("*.ts in D:/Pi/x")), "pi-fff find: renderCall title goes in the box");
998
1043
  assert(fffOut.some((l: string) => l.includes("6 more lines")), "pi-fff find: renderResult summary goes in the box");
999
1044
 
1045
+ // pi-fff default mode (`ffgrep`/`fffind`): same renderer shape, different names.
1046
+ for (const n of ["ffgrep", "fffind"]) {
1047
+ const t = { toolName: n, toolDefinition: { renderCall: () => fffCall, renderResult: () => fffRes } };
1048
+ assert(
1049
+ ToolExecutionComponent.prototype.getRenderShell.call(t) === "self",
1050
+ `pi-fff ${n}: shell forced to self (default mode is boxed too)`,
1051
+ );
1052
+ const out = (ToolExecutionComponent.prototype.getResultRenderer.call(t) as any)(
1053
+ { content: [{ type: "text", text: "src/a.ts\nsrc/b.ts" }] },
1054
+ { expanded: false, isPartial: false },
1055
+ th,
1056
+ { args: { pattern: "*.ts" }, state: {} },
1057
+ ).render(60);
1058
+ assert(
1059
+ out[0].startsWith("╭") && out.some((l: string) => l.includes("6 more lines")),
1060
+ `pi-fff ${n}: result + summary merged into one box`,
1061
+ );
1062
+ }
1063
+
1064
+ // pi core `powershell` / `ls` spread their renderers into the tool object and set no
1065
+ // renderShell, so pi's "default" shell used to render them as a bare bg block (see the
1066
+ // OWN_BOX block below for the registration half of this fix).
1067
+ for (const [n, t] of [
1068
+ ["powershell", createPowerShellToolDefinition(cwd)],
1069
+ ["ls", createLsToolDefinition(cwd)],
1070
+ ] as const) {
1071
+ assert(
1072
+ ToolExecutionComponent.prototype.getRenderShell.call({ toolName: n, toolDefinition: t }) === "self",
1073
+ `pi core ${n}: shell forced to self (joins our box, not a bg block)`,
1074
+ );
1075
+ }
1076
+
1000
1077
  const oursFind = { toolName: "find", toolDefinition: { renderShell: "self", renderCall: () => fffCall, renderResult: () => fffRes } };
1001
1078
  assert(
1002
1079
  (ToolExecutionComponent.prototype.getCallRenderer.call(oursFind) as any)() === fffCall &&
@@ -1067,7 +1144,35 @@ if (isMain(import.meta.url)) {
1067
1144
  "OWN_BOX: our own tools + boxed tools registered",
1068
1145
  );
1069
1146
  assert(!OWN_BOX.has("mcp") && !OWN_BOX.has("source_check"), "OWN_BOX: third-party renderers not tightened");
1070
- assert(hasOwnRendererDef(mcpTool) && !hasOwnRendererDef(noRenderer), "tightened benchmark: tool renderer definition");
1147
+ // pi core `powershell`/`ls` spread their renderer functions onto the tool object and
1148
+ // set no renderShell; that is exactly the shape that used to fall into pi's "default"
1149
+ // shell and render as a bare bg block. Registering them through minimal() boxes them.
1150
+ const coreReg: Record<string, any> = {};
1151
+ arnativeTools({ registerTool: (t: any) => { coreReg[t.name] = t; }, registerToolRenderer: () => {}, on: () => {} } as never);
1152
+ for (const [n, t] of [
1153
+ ["powershell", createPowerShellToolDefinition(cwd)],
1154
+ ["ls", createLsToolDefinition(cwd)],
1155
+ ] as const) {
1156
+ const def = t as any;
1157
+ assert(
1158
+ Boolean(def.renderCall) && Boolean(def.renderResult),
1159
+ `pi core ${n}: the definition pi runs spreads its renderers onto the object`,
1160
+ );
1161
+ assert(
1162
+ ToolExecutionComponent.prototype.getRenderShell.call({ toolName: n, toolDefinition: def }) === "self",
1163
+ `pi core ${n}: shell forced to self (joins our box, not a bg block)`,
1164
+ );
1165
+ }
1166
+ // The guard is load-bearing: `getPowerShellConfig()` throws off Windows, and our
1167
+ // registration activates a tool the builtin deliberately leaves off.
1168
+ assert(
1169
+ coreReg.ls?.renderShell === "self",
1170
+ "pi core ls: registered through minimal() (boxing is live)",
1171
+ );
1172
+ assert(
1173
+ process.platform === "win32" ? coreReg.powershell?.renderShell === "self" : coreReg.powershell === undefined,
1174
+ "pi core powershell: registered on Windows only (getPowerShellConfig throws elsewhere)",
1175
+ ); assert(hasOwnRendererDef(mcpTool) && !hasOwnRendererDef(noRenderer), "tightened benchmark: tool renderer definition");
1071
1176
  assert(
1072
1177
  capped(th, ["a", "b", "c"], false).length === CALL_ROWS + 1 &&
1073
1178
  capped(th, ["a", "b", "c"], true).length === 3 &&
@@ -238,7 +238,7 @@ export class PackageUpdateBoxComponent implements Component {
238
238
  const th = this.th;
239
239
  const fg = (color: string, text: string) => (th ? th.fg(color, text) : text);
240
240
  const rows = [
241
- fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
241
+ fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
242
242
  `${fg("muted", "Package updates are available. Run ")}${fg("accent", "pi update --extensions")}`,
243
243
  fg("muted", "Packages:"),
244
244
  ...this.packages.map((pkg) => `- ${pkg}`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-arnative",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "Refined aesthetics. Cohesive tools. Built for Pi. Warm themes, rounded tool boxes, tabbed header, compact footer and a /usage dashboard.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "scripts": {
43
43
  "dev": "pi --extension ./extensions/tools.ts --extension ./extensions/section-headers.ts --extension ./extensions/footer.ts --extension ./extensions/timestamps.ts --extension ./extensions/ui-render-tweaks.ts --extension ./extensions/usage.ts --extension ./extensions/arnative.ts",
44
- "test": "node lib/box.ts && node lib/ansi.ts && node lib/color.ts && node lib/shortcuts.ts && node lib/preset-store.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/section-headers.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts && node extensions/arnative.ts",
44
+ "test": "node lib/box.ts && node lib/ansi.ts && node lib/color.ts && node lib/shortcuts.ts && node lib/preset-store.ts && node themes/gen-themes.mjs --check && node extensions/tools.ts && node extensions/footer.ts && node extensions/timestamps.ts && node extensions/ui-render-tweaks.ts && node extensions/usage.ts && node extensions/arnative.ts && node --test",
45
45
  "themes": "node themes/gen-themes.mjs",
46
46
  "release:check": "node scripts/release-check.mjs pre-tag",
47
47
  "prepublishOnly": "npm test && node scripts/release-check.mjs pre-publish"