@bismawy/pi-arnative 0.3.6 → 0.3.8

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.8] - 2026-10-08
4
+
5
+ ### Added
6
+ - New footer preset **Arnative (Minimal)**, selectable in `/arnative` → Footers. It shows a single head row (cwd, clock, diff, git, model) with a dim `▲` at the end of each side. Each head's own text is the click target — anywhere on the cwd/git string toggles the left side (extension statuses), anywhere on the model string toggles the right side (speed/cache/token) — while the blank gap between them is inert, so a stray click on empty space cannot expand anything. Opened sides show `▼` and their rows; the two sides open independently. Ctrl+alt+M (alt+M on Windows/WSL) toggles both, so it also works in `tuiMode: "regular"` where the terminal owns the mouse. Zone columns are measured from the row this module places itself rather than mirrored from `pairRow`, because the ellipsis on a truncated head shifts every column after it (`extensions/footer.ts`, `lib/shortcuts.ts`).
7
+ - The footer shows the working tree's diff next to the session clock only while there is one: `\uf4d2 +500 -100`, with the same green/red the diff body uses (`toolDiffAdded`/`toolDiffRemoved`). It comes from `git diff HEAD --numstat` in the existing `refreshGit` pass (one extra spawn, nothing new on the per-frame path) and is hidden when the tree is clean or git cannot answer (not a repo, no binary) — no fake `+0 -0`, no dangling icon. A repo with an unborn `HEAD` (no commit yet) falls back to diffing against the empty tree, so a staged first commit still counts. Note the count is the whole uncommitted change, not just this session's edits: it includes anything done before or outside the session. Untracked files are not part of a diff and stay counted by the `~N` worktree segment (`extensions/footer.ts`).
8
+
9
+ ### Fixed
10
+ - The footer's session clock no longer restarts when you switch sessions with `/resume`. The reset came from `session_start`, which set the start to `Date.now()` for every reason but `"reload"`. It now reads the session header's `timestamp` (the moment the session was created) for `"resume"`/`"startup"`, so the duration reflects the session's real age; `"new"`/`"fork"` write a fresh header and still start at zero. A missing or unparsable header falls back to now (`extensions/footer.ts`).
11
+ - Every third-party tool that ships its own `renderCall`/`renderResult` now renders in the arnative box, not just the ones on a hardcoded name list. The predicate was an allowlist (`ls`, `powershell`, `ffgrep`, `fffind`, `find`, `grep`, `BOXED_TOOLS`, MCP), so any other extension's tool — present or future — fell through to pi's `contentBox`: a padded background block with no border. It is now shape-based: boxed unless the tool declares `renderShell: "self"` (arnative's own boxes) or is in `NOT_BOXED` (`extensions/tools.ts`).
12
+ - The inter-box gap is now stripped for the generic box path too. The gap-strip patch keyed on a name list (`OWN_BOX`), so a tool boxed by the widened predicate but not registered through `minimal()`/`BOXED_TOOLS` kept the leading `Spacer` and floated a blank row above its box (`extensions/tools.ts`).
13
+
14
+ ## [0.3.7] - 2026-10-05
15
+
16
+ ### Fixed
17
+ - 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`).
18
+ - 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`).
19
+ - 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`).
20
+ - The package-update box title lost the double space after its glyph (`extensions/ui-render-tweaks.ts`).
21
+
22
+ ### Changed
23
+ - 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`).
24
+
3
25
  ## [0.3.6] - 2026-10-05
4
26
 
5
27
  ### Fixed
package/README.md CHANGED
@@ -98,6 +98,8 @@ npm test # Run self-checks & theme generator check
98
98
  npm run themes # Rebuild all theme variants
99
99
  ```
100
100
 
101
+ **CI:** `.github/workflows/ci.yml` runs `npm install` + `npm test` on every push to `main` and every pull request, against Node 22.x (the floor the peer packages require) and 24.x (current LTS). No lockfile is committed, so the workflow installs without a cache key. The suite must pass in a clean checkout — a test that reads `process.cwd()` or a machine-local path will fail there.
102
+
101
103
  **Releasing:** `scripts/release-check.mjs` is a read-only preflight (never writes, tags, pushes or publishes) with one stage per step of the release:
102
104
 
103
105
  | Stage | Run before | Checks |
@@ -358,6 +358,8 @@ if (isMain(import.meta.url)) {
358
358
  const footerLines = footerPicker.render(50);
359
359
  assert(footerLines.some((l) => l.includes("Arnative · Footers")), "footers picker title");
360
360
  assert(footerLines.some((l) => l.includes("● Pi (system)")), "footers picker marks the active preset");
361
+ assert(footerLines.some((l) => l.includes("Arnative (Minimal)")), "footers picker lists the minimal preset");
362
+ assert(footerLines.filter((l) => l.includes("Arnative (")).length === 2, "both arnative footer presets are selectable");
361
363
 
362
364
  console.log("arnative.ts self-check OK");
363
365
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { AssistantMessage } from "@earendil-works/pi-ai";
13
13
  import { CustomEditor, FooterComponent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
14
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
+ import { truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
15
15
  import { execFile } from "node:child_process";
16
16
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
@@ -19,7 +19,7 @@ import { dirname, join, resolve } from "node:path";
19
19
  import { stripAnsi } from "../lib/ansi.ts";
20
20
  import { assert, isMain } from "../lib/check.ts";
21
21
  import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
22
- import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
22
+ import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD, SHORTCUT_TOGGLE_FOOTER } from "../lib/shortcuts.ts";
23
23
  import { formatTokens } from "../lib/usage-store.ts";
24
24
  import { FOOTER_PRESET_KEY as FOOTER_PRESET_CONFIG_KEY, loadChoice, resetPresetCache, saveChoice } from "../lib/preset-store.ts";
25
25
 
@@ -32,9 +32,21 @@ import { FOOTER_PRESET_KEY as FOOTER_PRESET_CONFIG_KEY, loadChoice, resetPresetC
32
32
  // in both — pi's `setEditorComponent()` would close the picker mid-preview). The picker
33
33
  // lives in arnative.ts, so the choice is shared through globalThis like the header preset.
34
34
  // Listed built-in-first: the `(Full)` suffix is rendered dim, so the plain row reads cleaner on top.
35
- export const FOOTER_PRESETS = ["Pi (system)", "Arnative (Full)"] as const;
35
+ export const FOOTER_PRESETS = ["Pi (system)", "Arnative (Full)", "Arnative (Minimal)"] as const;
36
36
  export type FooterPreset = (typeof FOOTER_PRESETS)[number];
37
37
  export const DEFAULT_FOOTER_PRESET: FooterPreset = "Arnative (Full)";
38
+ /** Presets that this extension's registrar draws (everything but pi's built-in footer). */
39
+ const ARNATIVE_PRESETS: readonly FooterPreset[] = ["Arnative (Full)", "Arnative (Minimal)"];
40
+ /** Collapse state of the minimal footer: one flag per side, so a click on the left
41
+ * head does not open the right metrics. Session-scoped, not persisted. */
42
+ const MINIMAL_EXPAND_KEY = Symbol.for("pi-arnative.minimalFooter.expanded");
43
+ export function minimalExpanded(): { left: boolean; right: boolean } {
44
+ const slot = (globalThis as Record<symbol, { left: boolean; right: boolean } | undefined>)[MINIMAL_EXPAND_KEY];
45
+ if (slot) return slot;
46
+ const fresh = { left: false, right: false };
47
+ (globalThis as Record<symbol, { left: boolean; right: boolean }>)[MINIMAL_EXPAND_KEY] = fresh;
48
+ return fresh;
49
+ }
38
50
  const FOOTER_PRESET_KEY = Symbol.for("pi-arnative.footerPreset");
39
51
  const FOOTER_REGISTRAR_KEY = Symbol.for("pi-arnative.footerRegistrar");
40
52
 
@@ -56,7 +68,6 @@ export function applyFooterPreset(ctx: ExtensionContext): void {
56
68
  const register = (globalThis as Record<symbol, ((ctx: ExtensionContext) => void) | undefined>)[FOOTER_REGISTRAR_KEY];
57
69
  register?.(ctx);
58
70
  }
59
-
60
71
  /** Select a preset and apply it immediately (the footer swaps on the spot). */
61
72
  export function setFooterPreset(name: FooterPreset, ctx: ExtensionContext): void {
62
73
  (globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY] = name;
@@ -84,6 +95,10 @@ type GitInfo = {
84
95
  /** Absent (null) when git could not answer — never a false "-"/"clean". */
85
96
  tag: string | null;
86
97
  uncommitted: number | null;
98
+ /** Working tree vs HEAD, tracked files only (an untracked file is not a diff).
99
+ * Null when git could not answer. */
100
+ added: number | null;
101
+ removed: number | null;
87
102
  ahead: number;
88
103
  behind: number;
89
104
  } | null;
@@ -94,6 +109,9 @@ let currentThinkingLevel: string | undefined = undefined;
94
109
  let currentModel: { id: string; name?: string; provider?: string } | undefined = undefined;
95
110
  let rerender: (() => void) | null = null;
96
111
  let lastPokeMs = 0;
112
+ /** Column ranges of the minimal footer's disclosure markers, recorded per render
113
+ * (mouse coordinates are component-local, so they must come from the same frame). */
114
+ let minimalRanges: { left: [number, number]; right: [number, number] } | null = null;
97
115
 
98
116
  const SESSION_START_KEY = Symbol.for("pi-arnative.sessionStartMs");
99
117
  let sessionStartMs: number = (globalThis as Record<symbol, number>)[SESSION_START_KEY] || Date.now();
@@ -176,7 +194,11 @@ let gitRefreshQueuedCwd: string | null = null;
176
194
  * (no binary, or a repo the user does not own) are omitted, so a fresh user still sees
177
195
  * the branch but never a fabricated "-" or "clean".
178
196
  */
179
- export function gitParts(info: GitInfo, acc: (t: string) => string, soft: (t: string) => string): string[] {
197
+ export function gitParts(
198
+ info: GitInfo,
199
+ acc: (t: string) => string,
200
+ soft: (t: string) => string,
201
+ ): string[] {
180
202
  if (!info) return [];
181
203
  const branch = `${acc("\uf126")} ${soft(info.branch)}${info.ahead > 0 ? ` ${soft(`↑${info.ahead}`)}` : ""}${info.behind > 0 ? ` ${soft(`↓${info.behind}`)}` : ""}`;
182
204
  const parts = [branch];
@@ -185,6 +207,23 @@ export function gitParts(info: GitInfo, acc: (t: string) => string, soft: (t: st
185
207
  return parts;
186
208
  }
187
209
 
210
+ /**
211
+ * Working-tree diff vs HEAD as `+500 -100`, or `""` when there is nothing to show:
212
+ * a clean tree, or git cannot answer (not a repo, no binary, unreadable HEAD). This is
213
+ * the whole uncommitted change, not just what this session touched — a snapshot at
214
+ * session start would be needed for that, which is not worth the cost here.
215
+ */
216
+ export function formatDiffStat(
217
+ info: GitInfo,
218
+ acc: (t: string) => string,
219
+ pos: (t: string) => string,
220
+ neg: (t: string) => string,
221
+ ): string {
222
+ if (!info || info.added === null || info.removed === null) return "";
223
+ if (info.added + info.removed === 0) return "";
224
+ return `${acc("\uf4d2")} ${pos(`+${info.added}`)} ${neg(`-${info.removed}`)}`;
225
+ }
226
+
188
227
  async function refreshGit(cwd: string): Promise<void> {
189
228
  if (gitRefreshInFlight) {
190
229
  gitRefreshQueuedCwd = cwd;
@@ -199,19 +238,26 @@ async function refreshGit(cwd: string): Promise<void> {
199
238
  (globalThis as Record<symbol, any>)[GIT_KEY] = null;
200
239
  return;
201
240
  }
202
- const [tag, status, sync] = await Promise.all([
241
+ const [tag, status, sync, numstat] = await Promise.all([
203
242
  run("git", ["describe", "--tags", "--abbrev=0"], cwd),
204
243
  run("git", ["status", "--porcelain"], cwd),
205
244
  run("git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd),
245
+ run("git", ["diff", "HEAD", "--numstat"], cwd),
206
246
  ]);
207
247
  const uncommitted = status.ok ? status.out.split("\n").filter((l) => l.trim().length > 0).length : null;
208
248
  const parts = sync.ok && sync.out !== "" ? sync.out.split(/\s+/).map(Number) : [];
209
249
  const behind = parts.length > 0 && parts[0] > 0 ? parts[0] : 0;
210
250
  const ahead = parts.length > 1 && parts[1] > 0 ? parts[1] : 0;
251
+ // An unborn HEAD (a repo with no commit yet) makes `diff HEAD` fail; compare the
252
+ // working tree against the canonical empty tree so a staged first commit still counts.
253
+ const numstat2 = numstat.ok ? numstat : await run("git", ["diff", "--numstat", "4b825dc642cb6eb9a060e54bf8d69288fbee4904"], cwd);
254
+ const { added, removed } = sumNumstat(numstat2.out);
211
255
  git = {
212
256
  branch,
213
257
  tag: tag.ok && tag.out ? tag.out : null,
214
258
  uncommitted,
259
+ added: numstat2.ok ? added : null,
260
+ removed: numstat2.ok ? removed : null,
215
261
  ahead,
216
262
  behind,
217
263
  };
@@ -226,6 +272,43 @@ async function refreshGit(cwd: string): Promise<void> {
226
272
  }
227
273
  }
228
274
 
275
+ /**
276
+ * Head row for the minimal footer, plus the exact click zones (0-based, inclusive
277
+ * columns) of its two heads. Placing the row here instead of guessing at `pairRow`'s
278
+ * arithmetic is deliberate: `pairRow` truncates with an ellipsis, which shifts columns,
279
+ * and a mirror of that logic silently drifts at narrow widths — the bug where a click on
280
+ * blank space toggled a side. Measuring the placed cells cannot drift. The blank gap and
281
+ * any padding stay inert; a head the row cannot fit gets `[-1, -1]`.
282
+ */
283
+ export function placeMinimalHead(
284
+ left: string,
285
+ right: string,
286
+ width: number,
287
+ ): { row: string; left: [number, number]; right: [number, number] } {
288
+ const lw = visibleWidth(left);
289
+ const rw = visibleWidth(right);
290
+ if (lw + 1 + rw <= width) {
291
+ const rightStart = width - rw;
292
+ return {
293
+ row: left + " ".repeat(rightStart - lw) + right,
294
+ left: lw > 0 ? [0, lw - 1] : [-1, -1],
295
+ right: rw > 0 ? [rightStart, width - 1] : [-1, -1],
296
+ };
297
+ }
298
+ // Too wide: the left head is cut, the right one keeps its edge column like pairRow.
299
+ const room = Math.max(1, width - Math.min(rw, width) - 1);
300
+ const lCut = truncateToWidth(left, room);
301
+ const rCut = truncateToWidth(right, width - room - 1);
302
+ const lCutW = visibleWidth(lCut);
303
+ const rCutW = visibleWidth(rCut);
304
+ const rightStart = lCutW + 1;
305
+ return {
306
+ row: lCut + " " + rCut,
307
+ left: lCutW > 0 ? [0, lCutW - 1] : [-1, -1],
308
+ right: rCutW > 0 ? [rightStart, rightStart + rCutW - 1] : [-1, -1],
309
+ };
310
+ }
311
+
229
312
  function formatModelName(
230
313
  model: { id: string; name?: string; provider?: string } | undefined,
231
314
  thinkingLevel: string | undefined,
@@ -237,7 +320,7 @@ function formatModelName(
237
320
  const { name, provider } = modelDisplayParts({ id: model.id, name: model.name, provider: model.provider });
238
321
  const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
239
322
  const capThinking = thinkingLevel && thinkingLevel !== "off" ? capitalize(thinkingLevel) : "";
240
- const thinkStr = capThinking ? `${acc("\udb80\udf35")} ${soft(capThinking)} ${dim("·")} ` : "";
323
+ const thinkStr = capThinking ? `${acc("\uee9c")} ${soft(capThinking)} ${dim("·")} ` : "";
241
324
  return `${thinkStr}${acc(name)}${provStr}`;
242
325
  }
243
326
 
@@ -254,6 +337,30 @@ export function shortenCwd(raw: string): string {
254
337
  return head + parts.slice(-2).join("/");
255
338
  }
256
339
 
340
+ // Session age must survive `/resume`: the header holds the moment the session was
341
+ // created, so reopening it shows the real age instead of restarting at 0. `new` and
342
+ // `fork` write a fresh header, so the same read yields "just now" there. `/reload`
343
+ // keeps the in-memory value (the header would be identical anyway).
344
+ export function resolveSessionStartMs(reason: string, headerTimestamp: string | undefined, now: number, prev: number): number {
345
+ if (reason === "reload") return prev;
346
+ const parsed = headerTimestamp ? Date.parse(headerTimestamp) : NaN;
347
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= now ? parsed : now;
348
+ }
349
+
350
+ /** Sum `git diff --numstat` output: `added<TAB>removed<TAB>path` per line. A `-`
351
+ * marks a binary file — no line count, so it is skipped, not counted as 0. */
352
+ export function sumNumstat(out: string): { added: number; removed: number } {
353
+ let added = 0;
354
+ let removed = 0;
355
+ for (const line of out.split("\n")) {
356
+ const [a, r] = line.split("\t");
357
+ if (!/^\d+$/.test(a ?? "") || !/^\d+$/.test(r ?? "")) continue;
358
+ added += Number(a);
359
+ removed += Number(r);
360
+ }
361
+ return { added, removed };
362
+ }
363
+
257
364
  export function formatDuration(ms: number): string {
258
365
  if (!ms || ms <= 0) return "-";
259
366
  const totalSec = Math.floor(ms / 1000);
@@ -516,6 +623,20 @@ export function getToolWorkingMessage(toolName: string, args?: any): string {
516
623
  export default function (pi: ExtensionAPI) {
517
624
  let runtimeGen = 0;
518
625
 
626
+ // Keyboard path for the minimal footer's disclosure: mouse clicks only exist in
627
+ // fullscreen mode, so pi's own rule ("always design a keyboard path") needs this.
628
+ // One key toggles both sides: expand all unless everything is already open.
629
+ const toggleMinimal = () => {
630
+ const state = minimalExpanded();
631
+ const allOpen = state.left && state.right;
632
+ state.left = !allOpen;
633
+ state.right = !allOpen;
634
+ poke();
635
+ };
636
+ for (const key of SHORTCUT_TOGGLE_FOOTER.keys) {
637
+ pi.registerShortcut(key, { description: "Expand/collapse the minimal footer", handler: toggleMinimal });
638
+ }
639
+
519
640
  // Assistant speed timer (user message timestamps come from extensions/timestamps.ts)
520
641
  pi.on("message_start", async (event) => {
521
642
  if (event.message.role === "assistant") {
@@ -529,7 +650,7 @@ export default function (pi: ExtensionAPI) {
529
650
  // ctx.ui closures) may still be alive; clear it before starting a new one.
530
651
  stopWorkingClock();
531
652
  if (event.reason !== "reload") {
532
- sessionStartMs = Date.now();
653
+ sessionStartMs = resolveSessionStartMs(event.reason, ctx.sessionManager.getHeader()?.timestamp, Date.now(), sessionStartMs);
533
654
  (globalThis as Record<symbol, number>)[SESSION_START_KEY] = sessionStartMs;
534
655
  currentModel = ctx.model;
535
656
  currentThinkingLevel = ctx.thinkingLevel;
@@ -578,16 +699,32 @@ export default function (pi: ExtensionAPI) {
578
699
  // fallback
579
700
  }
580
701
  const soft = (text: string) => fgAny(softName, text);
702
+ // Diff colors: the same theme slots pi uses for a diff body, with the
703
+ // plain soft color as fallback for a theme that omits them.
704
+ const colorOr = (name: string, fallback: (t: string) => string) => (t: string) => {
705
+ try {
706
+ return fgAny(name, t);
707
+ } catch {
708
+ return fallback(t);
709
+ }
710
+ };
711
+ const diffAdd = colorOr("toolDiffAdded", soft);
712
+ const diffDel = colorOr("toolDiffRemoved", soft);
581
713
  const sep = dim(" | ");
582
714
 
583
715
  const durationStr = formatDuration(Date.now() - sessionStartMs);
584
- const pDuration = `${acc("\uf017")} ${soft(durationStr)}`;
716
+ const diffStat = formatDiffStat(git, acc, diffAdd, diffDel);
717
+ const pDuration = `${acc("\uf017")} ${soft(durationStr)}${diffStat ? `${sep}${diffStat}` : ""}`;
585
718
  const cwdShort = shortenCwd(cwd);
719
+ const isMinimal = activeFooterPreset() === "Arnative (Minimal)";
720
+ const open = minimalExpanded();
721
+ const arrow = (expanded: boolean) => dim(expanded ? " ▼" : " ▲");
586
722
  let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
587
723
  if (git) {
588
724
  left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, soft).map((p) => `${sep}${p}`).join("")}`;
589
725
  }
590
- const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim);
726
+ if (isMinimal) left1 += arrow(open.left);
727
+ const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim) + (isMinimal ? arrow(open.right) : "");
591
728
 
592
729
  const statuses: ReadonlyMap<string, string> = (() => {
593
730
  try {
@@ -613,7 +750,7 @@ export default function (pi: ExtensionAPI) {
613
750
  if (/LITE/i.test(s)) mode = "LITE";
614
751
  else if (/ULTRA/i.test(s)) mode = "ULTRA";
615
752
  else if (/FULL/i.test(s)) mode = "FULL";
616
- return `${acc("\uef04")} ${soft("ponytail:")} ${bullet} ${soft(mode)}`;
753
+ return `${acc("\uef51")} ${soft("ponytail:")} ${bullet} ${soft(mode)}`;
617
754
  }
618
755
  if (s.includes("jev-eye")) {
619
756
  const isOff = s.includes("OFF") || s.includes("○");
@@ -652,8 +789,36 @@ export default function (pi: ExtensionAPI) {
652
789
  const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${soft(opt)}` : "", usageStr].filter(Boolean);
653
790
 
654
791
  // Fixed 3-row grid: head (cwd/model), then statuses and metrics each
655
- // wrapped inside their half of the width — no click toggle.
656
- return layoutFooterGrid(left1, right1, groups, metricCells, sep, ` ${dim("·")} `, width);
792
+ // wrapped inside their half of the width. Arnative (Minimal) starts collapsed
793
+ // and reveals the same rows on click/keypress, one side at a time.
794
+ const leftRows = isMinimal && !open.left ? [] : groups;
795
+ const rightRows = isMinimal && !open.right ? [] : metricCells;
796
+ if (isMinimal) {
797
+ // Own head row: it reports the zones measured from what it actually placed.
798
+ const placed = placeMinimalHead(left1, right1, width);
799
+ minimalRanges = { left: placed.left, right: placed.right };
800
+ const rest = layoutFooterGrid("", "", leftRows, rightRows, sep, ` ${dim("·")} `, width).slice(1);
801
+ return [placed.row, ...rest];
802
+ }
803
+ minimalRanges = null;
804
+ return layoutFooterGrid(left1, right1, leftRows, rightRows, sep, ` ${dim("·")} `, width);
805
+ },
806
+ handleMouse(event: TuiMouseEvent) {
807
+ // Clicks only reach here in fullscreen mode; the keyboard shortcut covers
808
+ // regular mode. Return handled on press so pi records the target — without
809
+ // that it never dispatches the follow-up click.
810
+ if (event.button !== "left" || event.y !== 0) return undefined;
811
+ const ranges = minimalRanges;
812
+ if (!ranges) return undefined;
813
+ const inside = (r: [number, number]) => event.x >= r[0] && event.x <= r[1];
814
+ const hit = inside(ranges.left) ? "left" : inside(ranges.right) ? "right" : null;
815
+ if (!hit) return undefined;
816
+ if (event.type === "press") return { handled: true, render: false };
817
+ if (event.type !== "click") return undefined;
818
+ const state = minimalExpanded();
819
+ state[hit] = !state[hit];
820
+ poke();
821
+ return { handled: true, render: true };
657
822
  },
658
823
  };
659
824
  });
@@ -969,16 +1134,53 @@ if (isMain(import.meta.url)) {
969
1134
  // rather than the old false "-"/"clean".
970
1135
  const A = (t: string) => t;
971
1136
  const S = (t: string) => t;
972
- const unknownGit = gitParts({ branch: "main", tag: null, uncommitted: null, ahead: 0, behind: 0 }, A, S);
1137
+ const unknownGit = gitParts({ branch: "main", tag: null, uncommitted: null, added: null, removed: null, ahead: 0, behind: 0 }, A, S);
973
1138
  assert(unknownGit.length === 1 && unknownGit[0]!.endsWith("main"), "unreadable git: branch only, no fake '-'/'clean'");
974
- const fullGit = gitParts({ branch: "main", tag: "v1.2.3", uncommitted: 2, ahead: 1, behind: 0 }, A, S);
1139
+ const fullGit = gitParts({ branch: "main", tag: "v1.2.3", uncommitted: 2, added: 500, removed: 100, ahead: 1, behind: 0 }, A, S);
975
1140
  assert(fullGit.length === 3 && fullGit[1]!.endsWith("v1.2.3") && fullGit[2]!.includes("~2"), "readable git: tag + dirty count present");
976
1141
  assert(gitParts(null, A, S).length === 0, "not a repo: no git segment at all");
1142
+ // Diff stat is clock-adjacent, so it renders with or without a repo.
1143
+ const dirty = { branch: "main", tag: null, uncommitted: 2, added: 500, removed: 100, ahead: 0, behind: 0 };
1144
+ assert(formatDiffStat(dirty, A, (t) => `<A>${t}</A>`, (t) => `<D>${t}</D>`).includes("<A>+500</A> <D>-100</D>"), "+/- use the diff colors");
1145
+ assert(formatDiffStat({ ...dirty, added: 0, removed: 0 }, A, A, A) === "", "a clean tree hides the diff segment");
1146
+ assert(formatDiffStat(null, A, A, A) === "", "no repo: the diff segment is hidden, never a fake 0");
1147
+ assert(formatDiffStat({ ...dirty, added: null, removed: null }, A, A, A) === "", "git cannot answer: the diff segment is hidden");
1148
+ assert(sumNumstat("12\t3\tsrc/a.ts\n-\t-\timg.png\n4\t0\tb.ts\n").added === 16, "numstat sums added lines and skips binary");
1149
+ assert(sumNumstat("12\t3\tsrc/a.ts\n-\t-\timg.png\n4\t0\tb.ts\n").removed === 3, "numstat sums removed lines and skips binary");
1150
+ assert(sumNumstat("").added === 0 && sumNumstat("").removed === 0, "empty numstat is zero, not NaN")
977
1151
 
978
1152
  // Footer presets: Arnative (Full) is the default; Pi (system) hands the footer slot back to pi (the editor stays arnative).
979
- assert(DEFAULT_FOOTER_PRESET === "Arnative (Full)" && FOOTER_PRESETS.length === 2, "Arnative (Full) is the default footer preset");
980
- assert(FOOTER_PRESETS.join(" | ") === "Pi (system) | Arnative (Full)", "built-in preset is listed first");
981
- assert(markedLabels(FOOTER_PRESETS, "Pi (system)").join(" | ") === "● Pi (system) | Arnative (Full)", "footer preset labels mark the active one");
1153
+ assert(DEFAULT_FOOTER_PRESET === "Arnative (Full)" && FOOTER_PRESETS.length === 3, "Arnative (Full) is the default footer preset");
1154
+ assert(FOOTER_PRESETS.join(" | ") === "Pi (system) | Arnative (Full) | Arnative (Minimal)", "built-in preset is listed first, minimal last");
1155
+ assert(markedLabels(FOOTER_PRESETS, "Pi (system)").join(" | ") === "● Pi (system) | Arnative (Full) | Arnative (Minimal)", "footer preset labels mark the active one");
1156
+ assert(ARNATIVE_PRESETS.join(" | ") === "Arnative (Full) | Arnative (Minimal)", "the arnative-drawn presets exclude pi's built-in footer");
1157
+ // Minimal disclosure: both sides start closed, then toggle independently.
1158
+ delete (globalThis as Record<symbol, unknown>)[MINIMAL_EXPAND_KEY];
1159
+ const expand0 = minimalExpanded();
1160
+ assert(expand0.left === false && expand0.right === false, "the minimal footer starts fully collapsed");
1161
+ expand0.left = true;
1162
+ assert(minimalExpanded().left === true && minimalExpanded().right === false, "one side opens without opening the other");
1163
+ delete (globalThis as Record<symbol, unknown>)[MINIMAL_EXPAND_KEY];
1164
+ // Disclosure arrows: the left zone ends the left head, the right one the terminal edge.
1165
+ const normal = placeMinimalHead("L".repeat(20), "R".repeat(10), 80);
1166
+ assert(visibleWidth(normal.row) === 80, "the head row fills the terminal width");
1167
+ assert(normal.left[0] === 0 && normal.left[1] === 19, "the left zone is exactly the left head text");
1168
+ assert(normal.right[0] === 70 && normal.right[1] === 79, "the right zone is exactly the right head text");
1169
+ assert(50 > normal.left[1] && 50 < normal.right[0], "the gap between the heads is inert");
1170
+ const clipped = placeMinimalHead("L".repeat(70), "R".repeat(20), 80);
1171
+ assert(visibleWidth(clipped.row) <= 80, "a cut head row never exceeds the width");
1172
+ assert(clipped.left[1] < clipped.right[0], "cut zones stay ordered and disjoint");
1173
+ assert(clipped.row.slice(clipped.left[0], clipped.left[1] + 1) === clipped.row.slice(0, clipped.left[1] + 1), "the left zone matches the placed text");
1174
+ // Squeezed: the left head keeps at least one column, the right one is dropped once
1175
+ // nothing is left for it. Both outcomes are measured from the placed row.
1176
+ const squeezed = placeMinimalHead("L".repeat(30), "R".repeat(30), 12);
1177
+ assert(visibleWidth(squeezed.row) <= 12, "a squeezed head row never exceeds the width");
1178
+ assert(squeezed.left[0] === 0, "the left head keeps its columns");
1179
+ assert(squeezed.right[0] === -1 || squeezed.right[0] > squeezed.left[1], "zones stay disjoint when squeezed");
1180
+ const noRight = placeMinimalHead("L", "R".repeat(40), 2);
1181
+ assert(noRight.right[0] === -1, "a right head with no room gets no zone");
1182
+ const onlyLeft = placeMinimalHead("L".repeat(5), "", 20);
1183
+ assert(onlyLeft.left[0] === 0 && onlyLeft.left[1] === 4 && onlyLeft.right[0] === -1, "an empty head has no zone");
982
1184
  let footerCleared = 0;
983
1185
  let editorTouched = 0;
984
1186
  const presetCtx: any = {
@@ -1010,5 +1212,18 @@ if (isMain(import.meta.url)) {
1010
1212
  else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
1011
1213
  resetPresetCache();
1012
1214
 
1215
+ // Self-check: formatModelName thinking icon
1216
+ const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s);
1217
+ assert(fmnRes.includes("\uee9c Low"), "formatModelName includes \\uee9c Low thinking indicator");
1218
+
1219
+ // Self-check: session age survives /resume (header timestamp wins over now).
1220
+ const NOW = Date.parse("2026-10-08T12:00:00.000Z");
1221
+ assert(resolveSessionStartMs("resume", "2026-10-08T09:00:00.000Z", NOW, NOW) === Date.parse("2026-10-08T09:00:00.000Z"), "resume restores the session age from the header");
1222
+ assert(resolveSessionStartMs("startup", "2026-10-08T11:59:59.000Z", NOW, NOW) === Date.parse("2026-10-08T11:59:59.000Z"), "startup reads the freshly written header");
1223
+ assert(resolveSessionStartMs("reload", undefined, NOW, 1234) === 1234, "reload keeps the in-memory start");
1224
+ assert(resolveSessionStartMs("resume", undefined, NOW, 1234) === NOW, "a missing header falls back to now");
1225
+ assert(resolveSessionStartMs("resume", "not-a-date", NOW, 1234) === NOW, "an unparsable header falls back to now");
1226
+ assert(resolveSessionStartMs("resume", "2026-10-08T13:00:00.000Z", NOW, 1234) === NOW, "a future header is clamped to now, never a negative age");
1227
+
1013
1228
  console.log("footer.ts self-check OK");
1014
1229
  }
@@ -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,
@@ -206,9 +210,9 @@ export function stripBlankEdges(lines: string[]): string[] {
206
210
  }
207
211
 
208
212
  // The inter-box gap is the built-in Spacer(1) of ToolExecutionComponent, stripped
209
- // with a prototype render patch (same pattern as the footer patch). Only lines we
210
- // draw ourselves are stripped: third-party tools use their own "self" shell too, so
211
- // the marker is a name list (OWN_BOX, filled by minimal() + BOXED_TOOLS), not the shell.
213
+ // with a prototype render patch (same pattern as the footer patch). Own boxes (our
214
+ // minimal() tools + codemode) and every box forced by the generic predicate below
215
+ // are stripped; a box-owning third party left untouched is not.
212
216
  const OWN_BOX = new Set<string>();
213
217
  const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; renderResult?: unknown } }): boolean =>
214
218
  Boolean(self?.toolDefinition?.renderCall || self?.toolDefinition?.renderResult);
@@ -236,30 +240,28 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
236
240
  const origRender = ToolExecutionComponent.prototype.render;
237
241
  ToolExecutionComponent.prototype.render = function (width: number): string[] {
238
242
  const lines = origRender.call(this, width);
239
- const name = (this as { toolName?: string }).toolName ?? "";
240
- return OWN_BOX.has(name) || !hasOwnRendererDef(this) ? stripEdgesKeepingImages(lines) : lines;
243
+ return OWN_BOX.has(this.toolName) || boxTheirRenderers(this) || !hasOwnRendererDef(this)
244
+ ? stripEdgesKeepingImages(lines)
245
+ : lines;
241
246
  };
242
247
  }
243
248
 
244
249
  // --- Tools without their own renderer: same box, one-line summary ---
245
250
  // Without this pi renders them as a plain bg block (name + 10 output lines). Our
246
251
  // renderer is installed through the prototype (same pattern as the gap patch) only
247
- // for tools that define no renderCall/renderResult of their own (our own tools and
248
- // pi-web-access etc. are untouched).
252
+ // for tools that define no renderCall/renderResult of their own; third-party
253
+ // renderers are handled by the generic predicate below.
249
254
  //
250
- // BOXED_TOOLS exceptions: the pi-web-access tools whose display names that package
251
- // hardcodes as "search "/"fetch "/"get_content " — their renderer content is used
252
- // as-is and only wrapped in our box. `source_check` is deliberately NOT boxed: its
253
- // partial phase (curator: URLs + approval state) would disappear because our partial
254
- // path returns nothing. The `todo` tool (@juicesharp/rpiv-todo) is boxed with a
255
- // check-square icon (\uf14a) and the toolPendingBg/toolSuccessBg backgrounds.
255
+ // BOXED_TOOLS is now a display-name map (plus the todo/cdp special render paths);
256
+ // boxing itself is generic (see boxTheirRenderers). The pi-web-access tools get a
257
+ // friendlier title ("search "/"fetch "/"get_content ") and their renderer content is
258
+ // reused inside our box. `todo` (@juicesharp/rpiv-todo) gets a check-square icon
259
+ // (\uf14a) and the toolPendingBg/toolSuccessBg backgrounds.
256
260
  //
257
- // MCP tools (pi-mcp-adapter) already use the "self" shell, but their result renderer
258
- // prints the WHOLE output on error (unbounded, outside the box) and their call args
259
- // can run to dozens of lines: a 1-line summary + [ctrl+o to expand] is tighter while
260
- // the invoked tool stays visible. pi-fff "override" mode re-registers `find`/`grep`
261
- // with its own renderers, replacing our boxed versions -> box theirs too (ours carry
262
- // renderShell "self" = minimal).
261
+ // MCP tools (pi-mcp-adapter) declare `renderShell: "default"`, so the generic
262
+ // predicate boxes them, but their result renderer prints the WHOLE output on error
263
+ // (unbounded) and their call args can run to dozens of lines: a 1-line summary +
264
+ // [ctrl+o to expand] is tighter while the invoked tool stays visible.
263
265
  const BOXED_TOOLS = new Map([
264
266
  ["web_search", "search"],
265
267
  ["fetch_content", "fetch"],
@@ -290,11 +292,20 @@ for (const n of BOXED_TOOLS.keys()) OWN_BOX.add(n);
290
292
 
291
293
  const MCP_TOOL = (name: string): boolean => name === "mcp" || name === "mcpScript" || name.startsWith("mcp__");
292
294
 
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");
295
+ // Generic, not a name list: any tool that ships its own renderers is boxed, unless
296
+ // it declares `renderShell: "self"` (our own `minimal()`/codemode boxes) or sits in
297
+ // NOT_BOXED. That covers every third-party extension without a registration step:
298
+ // pi-fff (`ffgrep`/`fffind`, and `find`/`grep` in its override mode), pi core's
299
+ // `powershell`/`ls` (their definitions spread `renderCall`/`renderResult` onto the
300
+ // tool object) and pi-mcp-adapter (`renderShell: "default"`) all fall in by shape.
301
+ //
302
+ // `source_check` is the single exception: its partial phase (curator URLs + approval
303
+ // state) would disappear because our partial path returns nothing.
304
+ const NOT_BOXED = new Set(["source_check"]);
305
+ const boxTheirRenderers = (self: any): boolean =>
306
+ Boolean(self.toolDefinition) &&
307
+ self.toolDefinition.renderShell !== "self" &&
308
+ !NOT_BOXED.has(self.toolName);
298
309
 
299
310
  // The MCP box title names the invoked tool (full args when expanded).
300
311
  const mcpInfo = (name: string, args: any, th: Theme): string => {
@@ -352,7 +363,11 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
352
363
  const origCall = proto.getCallRenderer;
353
364
  const origResult = proto.getResultRenderer;
354
365
  const origShell = proto.getRenderShell;
355
- const hasOwnRenderer = (self: any): boolean => Boolean(origCall.call(self) || origResult.call(self));
366
+ const hasOwnRenderer = (self: any): boolean =>
367
+ !boxTheirRenderers(self) && Boolean(origCall.call(self) || origResult.call(self));
368
+ // pi-fff splits title (renderCall) and result (renderResult) — both merge into one box.
369
+ // Its two naming modes are covered: `find`/`grep` (override) and `ffgrep`/`fffind` (default).
370
+ const FFF_TOOLS = ["find", "grep", "ffgrep", "fffind"];
356
371
 
357
372
  // A tool call with no registered definition (e.g. an unknown tool name that
358
373
  // errored with "Tool X not found") makes hasRendererDefinition() false, so the
@@ -366,13 +381,13 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
366
381
  };
367
382
 
368
383
  proto.getRenderShell = function (): string {
369
- if (usesTheirCallRenderer(this)) return "self";
384
+ if (boxTheirRenderers(this)) return "self";
370
385
  return hasOwnRenderer(this) ? origShell.call(this) : "self";
371
386
  };
372
387
 
373
388
  proto.getCallRenderer = function () {
374
389
  const name: string = this.toolName;
375
- const mine = usesTheirCallRenderer(this);
390
+ const mine = boxTheirRenderers(this);
376
391
  const own = origCall.call(this);
377
392
  // Chrome DevTools' own renderCall is a bare line; ours replaces it (icon + name).
378
393
  if (own && !mine && !CDP_NAME.has(name)) return own;
@@ -394,10 +409,9 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
394
409
  const name: string = this.toolName;
395
410
  const own = origResult.call(this);
396
411
  // pi-fff splits title (renderCall) and result (renderResult); both go in one box.
397
- const fff = FFF_OVERRIDE.includes(name) && this.toolDefinition?.renderShell !== "self";
412
+ const fff = FFF_TOOLS.includes(name) && this.toolDefinition?.renderShell !== "self";
398
413
  // MCP: their result renderer is deliberately skipped (unbounded error dump).
399
- if (own && !usesTheirCallRenderer(this)) return own;
400
- const boxed = BOXED_TOOLS.has(name);
414
+ if (own && !boxTheirRenderers(this)) return own;
401
415
  // Chrome DevTools are in BOXED_TOOLS for the title/icon only: their renderResult
402
416
  // (raw text, empty when collapsed) would drop our title row, so it is ignored.
403
417
  const cdp = CDP_NAME.has(name);
@@ -408,10 +422,12 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
408
422
  // pi-web-access reports failure via `details.error` without throwing, so
409
423
  // ctx.isError alone is not enough: without this the error box shows ✓.
410
424
  const isErr = Boolean(ctx.isError || result.isError || (result.details as { error?: unknown } | undefined)?.error);
411
- // Errors use our own path: pi-web-access' own error box is a box inside a
412
- // box. Their renderer content is only used on success.
425
+ // Their renderer content is reused inside our box unless it is unusable:
426
+ // MCP dumps the whole output on error (1-line summary instead), Chrome
427
+ // DevTools is a bare line, and errors take our own path (pi-web-access'
428
+ // own error box would nest a box inside a box).
413
429
  const theirs =
414
- (boxed || fff) && !isErr && !cdp
430
+ own && !isErr && !cdp && !MCP_TOOL(name)
415
431
  ? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
416
432
  : null;
417
433
  return new Lines((width) => {
@@ -660,6 +676,31 @@ function arnativeTools(pi: ExtensionAPI) {
660
676
 
661
677
  // Gap is only stripped for our own boxes (see OWN_BOX above).
662
678
 
679
+ // powershell: pi core registers it as a separate builtin (only on Windows); without
680
+ // our own registration it keeps pi's "default" shell and renders as a bg block, not a
681
+ // box (the same reason `bash` is re-registered below). Windows only: on any other
682
+ // platform `getPowerShellConfig()` throws, and an unregistered tool would stay off
683
+ // while our registration is active on load.
684
+ if (process.platform === "win32")
685
+ minimal(
686
+ pi,
687
+ createPowerShellTool(cwd),
688
+ (th) => th.fg("accent", "PS>"),
689
+ (a, _th, soft, expanded) => {
690
+ const cmd = (a.command ?? "").replace(/\r?\n/g, " ").trim();
691
+ return paintLinks(expanded ? cmd : smartTitle(cmd), soft);
692
+ },
693
+ );
694
+
695
+ // ls: no renderShell of its own, so pi's default shell turns it into a bg block.
696
+ minimal(
697
+ pi,
698
+ createLsTool(cwd),
699
+ (th) => th.fg("accent", "ls"),
700
+ (a, _th, soft) => soft(shortPath(a.path ?? ".")),
701
+ numRes((n) => `${n} entries`),
702
+ );
703
+
663
704
  // bash: `󰔟 $ <6 words>…` / `✓ $ <full title when expanded> 0.1s`
664
705
  minimal(
665
706
  pi,
@@ -874,11 +915,18 @@ if (isMain(import.meta.url)) {
874
915
  noDefOut.some((l: string) => l.includes('[ctrl+o to expand]')),
875
916
  "tool without a definition: title + summary + expand hint",
876
917
  );
918
+ // Generic predicate: any tool that ships its own renderers and no `renderShell: "self"`
919
+ // is boxed — no name list to maintain for new extensions.
877
920
  const withRenderer = { toolName: "bash", toolDefinition: { renderResult: () => undefined } };
878
921
  assert(ToolExecutionComponent.prototype.getRenderShell.call(noRenderer) === "self", "plain tool -> self shell");
879
922
  assert(
880
- ToolExecutionComponent.prototype.getRenderShell.call(withRenderer) === "default",
881
- "tool with a renderer -> its own shell (untouched)",
923
+ ToolExecutionComponent.prototype.getRenderShell.call(withRenderer) === "self",
924
+ "tool with its own renderer -> boxed too (generic predicate, no name list)",
925
+ );
926
+ const extTool = { toolName: "some_ext_tool", toolDefinition: { renderCall: () => undefined } };
927
+ assert(
928
+ ToolExecutionComponent.prototype.getRenderShell.call(extTool) === "self",
929
+ "unknown third-party tool with its own renderer -> boxed",
882
930
  );
883
931
  assert(
884
932
  toolSummary("memory_write", "Appended to MEMORY.md\n\nExisting MEMORY.md preview (1 lines)", {
@@ -997,6 +1045,38 @@ if (isMain(import.meta.url)) {
997
1045
  assert(fffOut.some((l: string) => l.includes("*.ts in D:/Pi/x")), "pi-fff find: renderCall title goes in the box");
998
1046
  assert(fffOut.some((l: string) => l.includes("6 more lines")), "pi-fff find: renderResult summary goes in the box");
999
1047
 
1048
+ // pi-fff default mode (`ffgrep`/`fffind`): same renderer shape, different names.
1049
+ for (const n of ["ffgrep", "fffind"]) {
1050
+ const t = { toolName: n, toolDefinition: { renderCall: () => fffCall, renderResult: () => fffRes } };
1051
+ assert(
1052
+ ToolExecutionComponent.prototype.getRenderShell.call(t) === "self",
1053
+ `pi-fff ${n}: shell forced to self (default mode is boxed too)`,
1054
+ );
1055
+ const out = (ToolExecutionComponent.prototype.getResultRenderer.call(t) as any)(
1056
+ { content: [{ type: "text", text: "src/a.ts\nsrc/b.ts" }] },
1057
+ { expanded: false, isPartial: false },
1058
+ th,
1059
+ { args: { pattern: "*.ts" }, state: {} },
1060
+ ).render(60);
1061
+ assert(
1062
+ out[0].startsWith("╭") && out.some((l: string) => l.includes("6 more lines")),
1063
+ `pi-fff ${n}: result + summary merged into one box`,
1064
+ );
1065
+ }
1066
+
1067
+ // pi core `powershell` / `ls` spread their renderers into the tool object and set no
1068
+ // renderShell, so pi's "default" shell used to render them as a bare bg block (see the
1069
+ // OWN_BOX block below for the registration half of this fix).
1070
+ for (const [n, t] of [
1071
+ ["powershell", createPowerShellToolDefinition(cwd)],
1072
+ ["ls", createLsToolDefinition(cwd)],
1073
+ ] as const) {
1074
+ assert(
1075
+ ToolExecutionComponent.prototype.getRenderShell.call({ toolName: n, toolDefinition: t }) === "self",
1076
+ `pi core ${n}: shell forced to self (joins our box, not a bg block)`,
1077
+ );
1078
+ }
1079
+
1000
1080
  const oursFind = { toolName: "find", toolDefinition: { renderShell: "self", renderCall: () => fffCall, renderResult: () => fffRes } };
1001
1081
  assert(
1002
1082
  (ToolExecutionComponent.prototype.getCallRenderer.call(oursFind) as any)() === fffCall &&
@@ -1067,7 +1147,35 @@ if (isMain(import.meta.url)) {
1067
1147
  "OWN_BOX: our own tools + boxed tools registered",
1068
1148
  );
1069
1149
  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");
1150
+ // pi core `powershell`/`ls` spread their renderer functions onto the tool object and
1151
+ // set no renderShell; that is exactly the shape that used to fall into pi's "default"
1152
+ // shell and render as a bare bg block. Registering them through minimal() boxes them.
1153
+ const coreReg: Record<string, any> = {};
1154
+ arnativeTools({ registerTool: (t: any) => { coreReg[t.name] = t; }, registerToolRenderer: () => {}, on: () => {} } as never);
1155
+ for (const [n, t] of [
1156
+ ["powershell", createPowerShellToolDefinition(cwd)],
1157
+ ["ls", createLsToolDefinition(cwd)],
1158
+ ] as const) {
1159
+ const def = t as any;
1160
+ assert(
1161
+ Boolean(def.renderCall) && Boolean(def.renderResult),
1162
+ `pi core ${n}: the definition pi runs spreads its renderers onto the object`,
1163
+ );
1164
+ assert(
1165
+ ToolExecutionComponent.prototype.getRenderShell.call({ toolName: n, toolDefinition: def }) === "self",
1166
+ `pi core ${n}: shell forced to self (joins our box, not a bg block)`,
1167
+ );
1168
+ }
1169
+ // The guard is load-bearing: `getPowerShellConfig()` throws off Windows, and our
1170
+ // registration activates a tool the builtin deliberately leaves off.
1171
+ assert(
1172
+ coreReg.ls?.renderShell === "self",
1173
+ "pi core ls: registered through minimal() (boxing is live)",
1174
+ );
1175
+ assert(
1176
+ process.platform === "win32" ? coreReg.powershell?.renderShell === "self" : coreReg.powershell === undefined,
1177
+ "pi core powershell: registered on Windows only (getPowerShellConfig throws elsewhere)",
1178
+ ); assert(hasOwnRendererDef(mcpTool) && !hasOwnRendererDef(noRenderer), "tightened benchmark: tool renderer definition");
1071
1179
  assert(
1072
1180
  capped(th, ["a", "b", "c"], false).length === CALL_ROWS + 1 &&
1073
1181
  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/lib/shortcuts.ts CHANGED
@@ -42,6 +42,9 @@ function build(letter: string): Shortcut {
42
42
  export const SHORTCUT_NEXT_TAB = build("t");
43
43
  export const SHORTCUT_NEW_SESSION = build("n");
44
44
  export const SHORTCUT_RELOAD = build("r");
45
+ /** Expand/collapse the minimal footer. Ctrl+alt+m on macOS/Linux, alt+m on Windows/WSL
46
+ * (same platform split as the shortcuts above). */
47
+ export const SHORTCUT_TOGGLE_FOOTER = build("m");
45
48
 
46
49
  if (isMain(import.meta.url)) {
47
50
  assert(SHORTCUT_RELOAD.keys[0] === Key.ctrlAlt("r"), "ctrl+alt+r is registered on every platform");
@@ -63,5 +66,11 @@ if (isMain(import.meta.url)) {
63
66
  assert(!SHORTCUT_RELOAD.matches("\x12"), "bare control char does not match ctrl+alt+r");
64
67
  assert(!SHORTCUT_RELOAD.matches("r"), "bare letter does not match ctrl+alt+r");
65
68
  assert(!SHORTCUT_NEXT_TAB.matches("\x1br"), "alt+r does not match the next-tab shortcut");
69
+ assert(SHORTCUT_TOGGLE_FOOTER.keys[0] === Key.ctrlAlt("m"), "ctrl+alt+m is registered on every platform");
70
+ assert(
71
+ SHORTCUT_TOGGLE_FOOTER.keys.includes(Key.alt("m")) === IS_WINDOWS_LIKE,
72
+ "alt+m is registered only where Ctrl+Alt is aliased away",
73
+ );
74
+ assert(!SHORTCUT_TOGGLE_FOOTER.matches("\x1b\x12"), "ctrl+alt+r does not match the footer toggle");
66
75
  console.log("shortcuts.ts self-check OK");
67
76
  }
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.8",
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"