@bismawy/pi-arnative 0.3.5 → 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 +18 -0
- package/extensions/section-headers.ts +42 -332
- package/extensions/tools.ts +116 -11
- package/extensions/ui-render-tweaks.ts +17 -15
- package/lib/ansi.ts +18 -0
- package/lib/box.ts +11 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
14
|
+
## [0.3.6] - 2026-10-05
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Fixed a startup crash: `Cannot read properties of null (reading 'fg')`. pi's `[Extension issues]` diagnostic box passed a null theme into the box renderer, which dereferenced it and killed the process with `uncaughtException`. It only surfaced on machines where an extension reported a problem (a conflicting shortcut, a failed `session_start`, …), because that is when the diagnostic is drawn. The renderer now draws an uncolored box for the first frame instead of throwing (`lib/box.ts`).
|
|
18
|
+
- The active theme now lives in a single `globalThis` slot. `/reload` re-imports extension modules, so two copies can coexist, and only one of them receives `session_start`. The copy that owns the `loadedResourcesContainer` patch used to keep a permanently-null theme; it now paints with the live theme (`lib/ansi.ts`, `extensions/section-headers.ts`, `extensions/ui-render-tweaks.ts`).
|
|
19
|
+
- Removed the `activeThemeProxy ?? Theme` fallback in the package-update box: `Theme` is a class, not an instance, so the fallback would itself have thrown `th.fg is not a function`. The box component now tolerates a null theme (`extensions/ui-render-tweaks.ts`).
|
|
20
|
+
|
|
3
21
|
## [0.3.5] - 2026-10-04
|
|
4
22
|
|
|
5
23
|
### 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 {
|
|
14
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import {
|
|
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
|
-
import { stripAnsi, themeOf } from "../lib/ansi.ts";
|
|
35
|
-
import {
|
|
36
|
-
import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
|
|
35
|
+
import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.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,
|
|
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,9 +52,10 @@ export const SECTION_ICONS: Record<TabKey, string> = {
|
|
|
52
52
|
Shortcut: "\uf11c", // nf-fa-keyboard_o
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// nf-cod-warning, width 1 — prefixes a pi diagnostic block's compact header.
|
|
56
|
+
const DIAGNOSTIC_ICON = "\uea6c";
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
|
|
58
59
|
|
|
59
60
|
const MODEL_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentModel");
|
|
60
61
|
const THINKING_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentThinking");
|
|
@@ -474,7 +475,7 @@ export class ArnativeHeader implements Component {
|
|
|
474
475
|
if (width < 36) {
|
|
475
476
|
return [centerLine(`pi v${VERSION}`, width)];
|
|
476
477
|
}
|
|
477
|
-
const th = this.themeProxy || (
|
|
478
|
+
const th = this.themeProxy || getActiveTheme<Themeish>();
|
|
478
479
|
const side = fgFirst(th, ["dim"], "│");
|
|
479
480
|
|
|
480
481
|
const infoLines: string[] = [];
|
|
@@ -646,6 +647,30 @@ export class ArnativeHeader implements Component {
|
|
|
646
647
|
}
|
|
647
648
|
}
|
|
648
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
|
+
|
|
649
674
|
// Intercept loadedResourcesContainer: capture data for the tabs and hide pi's own display
|
|
650
675
|
const SECTION_HEADER_KEY = Symbol.for("pi-arnative.sectionHeadersRewritten");
|
|
651
676
|
if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[SECTION_HEADER_KEY]) {
|
|
@@ -689,7 +714,11 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
|
|
|
689
714
|
const text = themedTextOf(child);
|
|
690
715
|
if (text.includes("[Extension issues]") && typeof child.render === "function") {
|
|
691
716
|
const origRender = child.render.bind(child);
|
|
692
|
-
|
|
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);
|
|
693
722
|
return origAddChild.call(this, child);
|
|
694
723
|
}
|
|
695
724
|
// Any other element inside loadedResourcesContainer (e.g. Spacer) is hidden too
|
|
@@ -729,7 +758,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
729
758
|
}
|
|
730
759
|
resourcesListingShown(quiet === false);
|
|
731
760
|
for (const key of ["Context", "Skills", "Extensions"] as const) tabStore.set(key, []);
|
|
732
|
-
|
|
761
|
+
// A context without a theme (project-trust prompt) must not drop the one we have.
|
|
762
|
+
const th = themeOf<Themeish>(ctx);
|
|
763
|
+
if (th) setActiveTheme(th);
|
|
733
764
|
// Register the header for the active preset
|
|
734
765
|
applyHeaderPreset(ctx);
|
|
735
766
|
});
|
|
@@ -748,324 +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
|
-
console.log("section-headers.ts self-check OK");
|
|
1071
|
-
}
|
package/extensions/tools.ts
CHANGED
|
@@ -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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 =>
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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 && !
|
|
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
|
-
|
|
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 &&
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
getMarkdownTheme,
|
|
22
22
|
initTheme,
|
|
23
23
|
InteractiveMode,
|
|
24
|
-
keyText,
|
|
25
24
|
Theme,
|
|
26
25
|
UserMessageComponent,
|
|
27
26
|
type ExtensionAPI,
|
|
@@ -40,12 +39,13 @@ import {
|
|
|
40
39
|
} from "@earendil-works/pi-tui";
|
|
41
40
|
import { readdirSync, readFileSync } from "node:fs";
|
|
42
41
|
import { renderBoxLines } from "../lib/box.ts";
|
|
43
|
-
import { ansiBgOpen, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
|
|
42
|
+
import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
|
|
44
43
|
import { assert, isMain } from "../lib/check.ts";
|
|
45
44
|
import { contrast, parseOklch, toHex } from "../lib/color.ts";
|
|
46
45
|
import { expandKeyName } from "../lib/format.ts";
|
|
47
46
|
|
|
48
|
-
|
|
47
|
+
type ActiveTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null;
|
|
48
|
+
const activeTheme = () => getActiveTheme<Exclude<ActiveTheme, null>>();
|
|
49
49
|
|
|
50
50
|
// Hide pi's built-in "Reloading keybindings, extensions, skills..." box
|
|
51
51
|
// (hardcoded in handleReloadCommand, no auto-hide). Patches Container.render
|
|
@@ -141,8 +141,8 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[SELECTIO
|
|
|
141
141
|
if (typeof origHighlight === "function") {
|
|
142
142
|
TuiAltScreen.prototype.applySelectionHighlight = function (text: string): string {
|
|
143
143
|
const highlighted = origHighlight.call(this, text);
|
|
144
|
-
const fgOpen = ansiFgOpen(
|
|
145
|
-
const bgOpen = ansiBgOpen(
|
|
144
|
+
const fgOpen = ansiFgOpen(activeTheme());
|
|
145
|
+
const bgOpen = ansiBgOpen(activeTheme(), "selectedBg");
|
|
146
146
|
return fgOpen && bgOpen ? recolorSelection(highlighted, fgOpen, bgOpen) : highlighted;
|
|
147
147
|
};
|
|
148
148
|
}
|
|
@@ -161,7 +161,7 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[PILL_KEY
|
|
|
161
161
|
layout: unknown,
|
|
162
162
|
width: number,
|
|
163
163
|
) {
|
|
164
|
-
return withAccentPill(this, () => origComposite.call(this, screen, layout, width),
|
|
164
|
+
return withAccentPill(this, () => origComposite.call(this, screen, layout, width), activeTheme());
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
}
|
|
@@ -190,7 +190,7 @@ if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, bool
|
|
|
190
190
|
};
|
|
191
191
|
const origUpdateList = proto.updateList;
|
|
192
192
|
proto.updateList = function (this: typeof proto) {
|
|
193
|
-
const th =
|
|
193
|
+
const th = activeTheme();
|
|
194
194
|
if (!th) return origUpdateList.call(this); // no theme yet -> pi's default
|
|
195
195
|
this.listContainer.clear();
|
|
196
196
|
for (let i = 0; i < this.options.length; i++) {
|
|
@@ -206,7 +206,8 @@ if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, bool
|
|
|
206
206
|
export default function uiRenderTweaks(pi: ExtensionAPI) {
|
|
207
207
|
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
208
208
|
pi.on("session_start", async (_event, ctx) => {
|
|
209
|
-
|
|
209
|
+
const th = themeOf<Exclude<ActiveTheme, null>>(ctx);
|
|
210
|
+
if (th) setActiveTheme(th);
|
|
210
211
|
});
|
|
211
212
|
|
|
212
213
|
// 4. `pi-jev-eye-review` contract message: use our own border box.
|
|
@@ -224,9 +225,9 @@ export default function uiRenderTweaks(pi: ExtensionAPI) {
|
|
|
224
225
|
// body text is unchanged.
|
|
225
226
|
export class PackageUpdateBoxComponent implements Component {
|
|
226
227
|
private packages: string[];
|
|
227
|
-
private th: { fg(c: string, t: string): string };
|
|
228
|
+
private th: { fg(c: string, t: string): string } | null;
|
|
228
229
|
|
|
229
|
-
constructor(packages: string[], th: { fg(c: string, t: string): string }) {
|
|
230
|
+
constructor(packages: string[], th: { fg(c: string, t: string): string } | null) {
|
|
230
231
|
this.packages = packages;
|
|
231
232
|
this.th = th;
|
|
232
233
|
}
|
|
@@ -235,10 +236,11 @@ export class PackageUpdateBoxComponent implements Component {
|
|
|
235
236
|
|
|
236
237
|
render(width: number): string[] {
|
|
237
238
|
const th = this.th;
|
|
239
|
+
const fg = (color: string, text: string) => (th ? th.fg(color, text) : text);
|
|
238
240
|
const rows = [
|
|
239
|
-
|
|
240
|
-
`${
|
|
241
|
-
|
|
241
|
+
fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
|
|
242
|
+
`${fg("muted", "Package updates are available. Run ")}${fg("accent", "pi update --extensions")}`,
|
|
243
|
+
fg("muted", "Packages:"),
|
|
242
244
|
...this.packages.map((pkg) => `- ${pkg}`),
|
|
243
245
|
];
|
|
244
246
|
return renderBoxLines(th, width, rows, undefined, "warning");
|
|
@@ -257,7 +259,7 @@ if (InteractiveMode?.prototype && !(globalThis as Record<symbol, boolean>)[PACKA
|
|
|
257
259
|
ui: { requestRender(): void };
|
|
258
260
|
};
|
|
259
261
|
self.chatContainer.addChild(new Spacer(1));
|
|
260
|
-
self.chatContainer.addChild(new PackageUpdateBoxComponent(packages,
|
|
262
|
+
self.chatContainer.addChild(new PackageUpdateBoxComponent(packages, activeTheme()));
|
|
261
263
|
self.ui.requestRender();
|
|
262
264
|
};
|
|
263
265
|
}
|
|
@@ -333,7 +335,7 @@ if (CompactionSummaryMessageComponent?.prototype && !(globalThis as Record<symbo
|
|
|
333
335
|
const proto = CompactionSummaryMessageComponent.prototype as any;
|
|
334
336
|
const origUpdateDisplay = proto.updateDisplay;
|
|
335
337
|
proto.updateDisplay = function (): void {
|
|
336
|
-
const th =
|
|
338
|
+
const th = activeTheme();
|
|
337
339
|
if (!th) return origUpdateDisplay.call(this);
|
|
338
340
|
const message = this.message as { tokensBefore?: number; summary?: string } | undefined;
|
|
339
341
|
const tokenStr = Number(message?.tokensBefore ?? 0).toLocaleString();
|
package/lib/ansi.ts
CHANGED
|
@@ -25,6 +25,19 @@ export function themeOf<T>(ctx: unknown): T | null {
|
|
|
25
25
|
return (ctx as { ui?: { theme?: T } } | null | undefined)?.ui?.theme ?? null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Shared active-theme slot. `/reload` re-imports an extension's modules, so two copies
|
|
29
|
+
// can coexist: one carries the session_start handler, the other owns a stacked UI patch.
|
|
30
|
+
// globalThis gives both the same theme, so the patched copy paints instead of drawing
|
|
31
|
+
// uncolored (or, before the null-guard fix, crashing). Set from `session_start`.
|
|
32
|
+
const THEME_PROXY_KEY = Symbol.for("pi-arnative.activeThemeProxy");
|
|
33
|
+
export function getActiveTheme<T>(): T | null {
|
|
34
|
+
return (globalThis as Record<symbol, T>)[THEME_PROXY_KEY] ?? null;
|
|
35
|
+
}
|
|
36
|
+
export function setActiveTheme<T>(th: T | null): void {
|
|
37
|
+
if (th) (globalThis as Record<symbol, T>)[THEME_PROXY_KEY] = th;
|
|
38
|
+
else delete (globalThis as Record<symbol, T>)[THEME_PROXY_KEY];
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
/** Painter for the `accentSoft` token, which only arnative themes define; other themes fall back to accent (probed once). */
|
|
29
42
|
export function accentSoftOf(th: { fg?(c: string, t: string): string } | null): (s: string) => string {
|
|
30
43
|
const fg = th?.fg?.bind(th);
|
|
@@ -46,6 +59,11 @@ if (isMain(import.meta.url)) {
|
|
|
46
59
|
assert(themeOf({ ui: { theme: "T" } }) === "T", "themeOf reads ctx.ui.theme");
|
|
47
60
|
assert(themeOf(null) === null, "themeOf tolerates a missing ctx");
|
|
48
61
|
|
|
62
|
+
setActiveTheme({ fg: (c, t) => `<${c}>${t}</${c}>` });
|
|
63
|
+
assert(getActiveTheme<object>() !== null, "active theme round-trips through globalThis");
|
|
64
|
+
setActiveTheme(null);
|
|
65
|
+
assert(getActiveTheme<object>() === null, "clearing the active theme leaves no stale slot");
|
|
66
|
+
|
|
49
67
|
const tag = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
|
|
50
68
|
assert(accentSoftOf(tag)("x") === "<accentSoft>x</accentSoft>", "accentSoftOf uses accentSoft when the theme defines it");
|
|
51
69
|
const noSoft = {
|
package/lib/box.ts
CHANGED
|
@@ -23,18 +23,21 @@ const applyBg = (line: string, width: number, bgFn: (text: string) => string): s
|
|
|
23
23
|
return bgFn(line + " ".repeat(pad));
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
/** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color.
|
|
27
|
+
* `theme` may be null: a box is patched in at module load, before `session_start`
|
|
28
|
+
* delivers the active theme. An uncolored box for that first frame beats a null
|
|
29
|
+
* deref that kills the process. */
|
|
30
|
+
export function renderBoxLines(theme: BoxTheme | null, width: number, rows: string[], bgType?: string, borderColor = "dim"): string[] {
|
|
31
|
+
const dim = (s: string) => (theme ? theme.fg(borderColor, s) : s);
|
|
29
32
|
const inner = Math.max(8, width - 4);
|
|
30
33
|
const lines = [boxEdge("╭", "╮", width, dim)];
|
|
31
34
|
for (const row of rows) {
|
|
32
35
|
for (const line of wrapTextWithAnsi(row, inner)) lines.push(boxRow(line, width, dim));
|
|
33
36
|
}
|
|
34
37
|
lines.push(boxEdge("╰", "╯", width, dim));
|
|
35
|
-
if (bgType && theme
|
|
38
|
+
if (bgType && theme?.bg) {
|
|
36
39
|
try {
|
|
37
|
-
return lines.map((l) => applyBg(l, width, (s) => theme
|
|
40
|
+
return lines.map((l) => applyBg(l, width, (s) => theme!.bg!(bgType, s)));
|
|
38
41
|
} catch {
|
|
39
42
|
return lines;
|
|
40
43
|
}
|
|
@@ -50,5 +53,8 @@ if (isMain(import.meta.url)) {
|
|
|
50
53
|
assert(plain[1] === "│ hello │", "content line padded flush");
|
|
51
54
|
assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "long line wrapped");
|
|
52
55
|
assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg ignored when the theme has no bg");
|
|
56
|
+
const nullTheme = renderBoxLines(null, 24, ["hello"], "customMessageBg");
|
|
57
|
+
assert(nullTheme.every((l) => visibleWidth(l) === 24), "null theme renders an uncolored box instead of throwing");
|
|
58
|
+
assert(nullTheme[0]!.startsWith("╭") && nullTheme[1] === "│ hello │", "null-theme frame and padding intact");
|
|
53
59
|
console.log("lib/box.ts OK");
|
|
54
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bismawy/pi-arnative",
|
|
3
|
-
"version": "0.3.
|
|
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/
|
|
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"
|