@bismawy/pi-arnative 0.3.7 → 0.3.9

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.9] - 2026-10-08
4
+
5
+ ### Fixed
6
+ - The README told you to press `ctrl+e` to expand a tool box. The key actually follows pi's `app.tools.expand` binding, whose default is `ctrl+o` (`lib/format.ts`, `extensions/tools.ts`). Docs only — no behaviour changed in this release.
7
+
8
+ ### Changed
9
+ - The README now states the footer facts it never listed: all three presets (`Arnative (Full)`, `Pi (system)`, `Arnative (Minimal)`), the Minimal preset's click behaviour and `ctrl+alt+m`, the session clock surviving `/resume`, and the working-tree diff beside it (`README.md`).
10
+
11
+ ## [0.3.8] - 2026-10-08
12
+
13
+ ### Added
14
+ - 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`).
15
+ - 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`).
16
+
17
+ ### Fixed
18
+ - 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`).
19
+ - 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`).
20
+ - 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`).
21
+
3
22
  ## [0.3.7] - 2026-10-05
4
23
 
5
24
  ### Fixed
package/README.md CHANGED
@@ -16,7 +16,8 @@ pi-arnative replaces pi’s default terminal chrome with a unified, cohesive vis
16
16
  - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, and Shortcut.
17
17
  > The Context/Skills/Extensions counts read pi's startup listing, which `"quietStartup": true` (and `"header"`) hides. Under those settings the counts show `—` rather than a misleading `0`; set `"quietStartup": false` to see the real numbers.
18
18
  - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call. Built-in `codemode` is boxed and collapsed to two rows (title + first script line) like `bash`/`grep`/`read`; nested calls and output sit behind `[ctrl+o to expand]` (shown whenever the collapsed summary hides more, including `edit` diffs); a failed run always shows its full output. The `chrome_devtools_*` tools box too, replacing their bare "Chrome DevTools: …" lines.
19
- - Enhanced Footer: fixed 3-row grid — directory/git line, then extension status paired with token metrics.
19
+ - Enhanced Footer: fixed 3-row grid — directory/git line, then extension status paired with token metrics. The clock keeps the session's real age across `/resume`, and the working tree's diff (`+500 -100`) sits beside it while there is one.
20
+ > Three presets via `/arnative` → **Footers**: `Arnative (Full)`, `Pi (system)`, and `Arnative (Minimal)` — a single head row whose two sides expand independently on click (`ctrl+alt+m` toggles both).
20
21
  > The branch is read straight from `.git/HEAD`, so it shows up without a git install. Tag, dirty count, and ahead/behind need the `git` binary — when it is missing (or the repo is owned by another user, which git refuses to touch) that half is left out rather than shown wrong. Run `git config --global --add safe.directory <repo>` (a local, non-synced setting) to make `git` cooperate with such a repo.
21
22
  - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
22
23
  - Usage Dashboard: /usage command to track tokens and session costs.
@@ -42,10 +43,11 @@ pi --extension ./extensions/footer.ts
42
43
  | `ctrl+alt+t` · `alt+t` | Cycle header tabs |
43
44
  | `ctrl+alt+r` · `alt+r` | Quick reload (`/reload`) |
44
45
  | `ctrl+alt+n` · `alt+n` | New session (`/new`) — requires keybinding below |
46
+ | `ctrl+alt+m` · `alt+m` | Expand/collapse both sides of the Minimal footer |
45
47
  | `/usage` | Show session usage & token metrics |
46
48
  | `/arnative` | Settings menu — Themes, Headers, Footers |
47
49
  | Click tab | Open selected header tab |
48
- | Click tool / `ctrl+e` | Toggle tool output box |
50
+ | Click tool / `ctrl+o` | Toggle tool output box |
49
51
 
50
52
  > **Notes:**
51
53
  > - **Windows / WSL:** Windows Terminal aliases `Ctrl+Alt` to AltGr; use `alt+…` (or set `"altGrAliasing": false` in WT profile).
@@ -98,6 +100,8 @@ npm test # Run self-checks & theme generator check
98
100
  npm run themes # Rebuild all theme variants
99
101
  ```
100
102
 
103
+ **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.
104
+
101
105
  **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
106
 
103
107
  | 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
  }
@@ -7,7 +7,8 @@
7
7
  * │ ✓ $ cmd smart… 0.1s │ final, collapsed; duration right-aligned
8
8
  * │ 󱞩 first output line │
9
9
  * ╰────────────────────────────╯
10
- * Click / ctrl+e = full title + detail (output dim; edit = toolDiff*).
10
+ * Click / the `app.tools.expand` key (ctrl+o by default) = full title + detail
11
+ * (output dim; edit = toolDiff*).
11
12
  * Collapsed summary: bash/write `󱞩 first line`; grep/find/read numeric
12
13
  * (→ N matches / → N files / N lines); edit `󱞩 +N / -M`. Expanded plain output
13
14
  * gets no summary line (no duplicated first line).
@@ -210,9 +211,9 @@ export function stripBlankEdges(lines: string[]): string[] {
210
211
  }
211
212
 
212
213
  // The inter-box gap is the built-in Spacer(1) of ToolExecutionComponent, stripped
213
- // with a prototype render patch (same pattern as the footer patch). Only lines we
214
- // draw ourselves are stripped: third-party tools use their own "self" shell too, so
215
- // the marker is a name list (OWN_BOX, filled by minimal() + BOXED_TOOLS), not the shell.
214
+ // with a prototype render patch (same pattern as the footer patch). Own boxes (our
215
+ // minimal() tools + codemode) and every box forced by the generic predicate below
216
+ // are stripped; a box-owning third party left untouched is not.
216
217
  const OWN_BOX = new Set<string>();
217
218
  const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; renderResult?: unknown } }): boolean =>
218
219
  Boolean(self?.toolDefinition?.renderCall || self?.toolDefinition?.renderResult);
@@ -240,30 +241,28 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
240
241
  const origRender = ToolExecutionComponent.prototype.render;
241
242
  ToolExecutionComponent.prototype.render = function (width: number): string[] {
242
243
  const lines = origRender.call(this, width);
243
- const name = (this as { toolName?: string }).toolName ?? "";
244
- return OWN_BOX.has(name) || !hasOwnRendererDef(this) ? stripEdgesKeepingImages(lines) : lines;
244
+ return OWN_BOX.has(this.toolName) || boxTheirRenderers(this) || !hasOwnRendererDef(this)
245
+ ? stripEdgesKeepingImages(lines)
246
+ : lines;
245
247
  };
246
248
  }
247
249
 
248
250
  // --- Tools without their own renderer: same box, one-line summary ---
249
251
  // Without this pi renders them as a plain bg block (name + 10 output lines). Our
250
252
  // renderer is installed through the prototype (same pattern as the gap patch) only
251
- // for tools that define no renderCall/renderResult of their own (our own tools and
252
- // pi-web-access etc. are untouched).
253
+ // for tools that define no renderCall/renderResult of their own; third-party
254
+ // renderers are handled by the generic predicate below.
253
255
  //
254
- // BOXED_TOOLS exceptions: the pi-web-access tools whose display names that package
255
- // hardcodes as "search "/"fetch "/"get_content " — their renderer content is used
256
- // as-is and only wrapped in our box. `source_check` is deliberately NOT boxed: its
257
- // partial phase (curator: URLs + approval state) would disappear because our partial
258
- // path returns nothing. The `todo` tool (@juicesharp/rpiv-todo) is boxed with a
259
- // check-square icon (\uf14a) and the toolPendingBg/toolSuccessBg backgrounds.
256
+ // BOXED_TOOLS is now a display-name map (plus the todo/cdp special render paths);
257
+ // boxing itself is generic (see boxTheirRenderers). The pi-web-access tools get a
258
+ // friendlier title ("search "/"fetch "/"get_content ") and their renderer content is
259
+ // reused inside our box. `todo` (@juicesharp/rpiv-todo) gets a check-square icon
260
+ // (\uf14a) and the toolPendingBg/toolSuccessBg backgrounds.
260
261
  //
261
- // MCP tools (pi-mcp-adapter) already use the "self" shell, but their result renderer
262
- // prints the WHOLE output on error (unbounded, outside the box) and their call args
263
- // can run to dozens of lines: a 1-line summary + [ctrl+o to expand] is tighter while
264
- // the invoked tool stays visible. pi-fff "override" mode re-registers `find`/`grep`
265
- // with its own renderers, replacing our boxed versions -> box theirs too (ours carry
266
- // renderShell "self" = minimal).
262
+ // MCP tools (pi-mcp-adapter) declare `renderShell: "default"`, so the generic
263
+ // predicate boxes them, but their result renderer prints the WHOLE output on error
264
+ // (unbounded) and their call args can run to dozens of lines: a 1-line summary +
265
+ // [ctrl+o to expand] is tighter while the invoked tool stays visible.
267
266
  const BOXED_TOOLS = new Map([
268
267
  ["web_search", "search"],
269
268
  ["fetch_content", "fetch"],
@@ -294,23 +293,20 @@ for (const n of BOXED_TOOLS.keys()) OWN_BOX.add(n);
294
293
 
295
294
  const MCP_TOOL = (name: string): boolean => name === "mcp" || name === "mcpScript" || name.startsWith("mcp__");
296
295
 
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.
296
+ // Generic, not a name list: any tool that ships its own renderers is boxed, unless
297
+ // it declares `renderShell: "self"` (our own `minimal()`/codemode boxes) or sits in
298
+ // NOT_BOXED. That covers every third-party extension without a registration step:
299
+ // pi-fff (`ffgrep`/`fffind`, and `find`/`grep` in its override mode), pi core's
300
+ // `powershell`/`ls` (their definitions spread `renderCall`/`renderResult` onto the
301
+ // tool object) and pi-mcp-adapter (`renderShell: "default"`) all fall in by shape.
302
+ //
303
+ // `source_check` is the single exception: its partial phase (curator URLs + approval
304
+ // state) would disappear because our partial path returns nothing.
305
+ const NOT_BOXED = new Set(["source_check"]);
304
306
  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));
307
+ Boolean(self.toolDefinition) &&
308
+ self.toolDefinition.renderShell !== "self" &&
309
+ !NOT_BOXED.has(self.toolName);
314
310
 
315
311
  // The MCP box title names the invoked tool (full args when expanded).
316
312
  const mcpInfo = (name: string, args: any, th: Theme): string => {
@@ -417,7 +413,6 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
417
413
  const fff = FFF_TOOLS.includes(name) && this.toolDefinition?.renderShell !== "self";
418
414
  // MCP: their result renderer is deliberately skipped (unbounded error dump).
419
415
  if (own && !boxTheirRenderers(this)) return own;
420
- const boxed = BOXED_TOOLS.has(name);
421
416
  // Chrome DevTools are in BOXED_TOOLS for the title/icon only: their renderResult
422
417
  // (raw text, empty when collapsed) would drop our title row, so it is ignored.
423
418
  const cdp = CDP_NAME.has(name);
@@ -428,10 +423,12 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
428
423
  // pi-web-access reports failure via `details.error` without throwing, so
429
424
  // ctx.isError alone is not enough: without this the error box shows ✓.
430
425
  const isErr = Boolean(ctx.isError || result.isError || (result.details as { error?: unknown } | undefined)?.error);
431
- // Errors use our own path: pi-web-access' own error box is a box inside a
432
- // box. Their renderer content is only used on success.
426
+ // Their renderer content is reused inside our box unless it is unusable:
427
+ // MCP dumps the whole output on error (1-line summary instead), Chrome
428
+ // DevTools is a bare line, and errors take our own path (pi-web-access'
429
+ // own error box would nest a box inside a box).
433
430
  const theirs =
434
- (boxed || fff) && !isErr && !cdp
431
+ own && !isErr && !cdp && !MCP_TOOL(name)
435
432
  ? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
436
433
  : null;
437
434
  return new Lines((width) => {
@@ -919,11 +916,18 @@ if (isMain(import.meta.url)) {
919
916
  noDefOut.some((l: string) => l.includes('[ctrl+o to expand]')),
920
917
  "tool without a definition: title + summary + expand hint",
921
918
  );
919
+ // Generic predicate: any tool that ships its own renderers and no `renderShell: "self"`
920
+ // is boxed — no name list to maintain for new extensions.
922
921
  const withRenderer = { toolName: "bash", toolDefinition: { renderResult: () => undefined } };
923
922
  assert(ToolExecutionComponent.prototype.getRenderShell.call(noRenderer) === "self", "plain tool -> self shell");
924
923
  assert(
925
- ToolExecutionComponent.prototype.getRenderShell.call(withRenderer) === "default",
926
- "tool with a renderer -> its own shell (untouched)",
924
+ ToolExecutionComponent.prototype.getRenderShell.call(withRenderer) === "self",
925
+ "tool with its own renderer -> boxed too (generic predicate, no name list)",
926
+ );
927
+ const extTool = { toolName: "some_ext_tool", toolDefinition: { renderCall: () => undefined } };
928
+ assert(
929
+ ToolExecutionComponent.prototype.getRenderShell.call(extTool) === "self",
930
+ "unknown third-party tool with its own renderer -> boxed",
927
931
  );
928
932
  assert(
929
933
  toolSummary("memory_write", "Appended to MEMORY.md\n\nExisting MEMORY.md preview (1 lines)", {
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.7",
3
+ "version": "0.3.9",
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",