@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 +22 -0
- package/README.md +2 -0
- package/extensions/arnative.ts +2 -0
- package/extensions/footer.ts +233 -18
- package/extensions/section-headers.ts +37 -349
- package/extensions/tools.ts +144 -36
- package/extensions/ui-render-tweaks.ts +1 -1
- package/lib/shortcuts.ts +9 -0
- package/package.json +2 -2
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 |
|
package/extensions/arnative.ts
CHANGED
|
@@ -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
|
}
|
package/extensions/footer.ts
CHANGED
|
@@ -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(
|
|
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("\
|
|
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
|
|
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
|
-
|
|
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("\
|
|
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
|
|
656
|
-
|
|
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 ===
|
|
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 {
|
|
14
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import {
|
|
16
|
+
import { userInfo } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import {
|
|
19
19
|
UserMessageComponent,
|
|
@@ -30,12 +30,12 @@ import {
|
|
|
30
30
|
type TuiMouseEvent,
|
|
31
31
|
type TuiMouseEventResult,
|
|
32
32
|
} from "@earendil-works/pi-tui";
|
|
33
|
+
import type { BoxTheme } from "../lib/box.ts";
|
|
33
34
|
import { renderBoxLines } from "../lib/box.ts";
|
|
34
35
|
import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
|
|
35
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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
|
-
}
|
package/extensions/tools.ts
CHANGED
|
@@ -24,6 +24,10 @@ import {
|
|
|
24
24
|
createEditTool,
|
|
25
25
|
createFindTool,
|
|
26
26
|
createGrepTool,
|
|
27
|
+
createLsTool,
|
|
28
|
+
createLsToolDefinition,
|
|
29
|
+
createPowerShellTool,
|
|
30
|
+
createPowerShellToolDefinition,
|
|
27
31
|
createReadTool,
|
|
28
32
|
createWriteTool,
|
|
29
33
|
getMarkdownTheme,
|
|
@@ -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).
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
248
|
-
//
|
|
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
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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)
|
|
258
|
-
//
|
|
259
|
-
// can run to dozens of lines: a 1-line summary +
|
|
260
|
-
// the invoked tool stays visible.
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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 =>
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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 && !
|
|
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
|
-
//
|
|
412
|
-
//
|
|
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
|
-
|
|
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) === "
|
|
881
|
-
"tool with
|
|
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
|
-
|
|
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
|
|
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.
|
|
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/
|
|
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"
|