@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 +19 -0
- package/README.md +6 -2
- package/extensions/arnative.ts +2 -0
- package/extensions/footer.ts +233 -18
- package/extensions/tools.ts +46 -42
- package/lib/shortcuts.ts +9 -0
- package/package.json +1 -1
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+
|
|
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 |
|
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
|
}
|
package/extensions/tools.ts
CHANGED
|
@@ -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+
|
|
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).
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
252
|
-
//
|
|
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
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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)
|
|
262
|
-
//
|
|
263
|
-
// can run to dozens of lines: a 1-line summary +
|
|
264
|
-
// the invoked tool stays visible.
|
|
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
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
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
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
//
|
|
432
|
-
//
|
|
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
|
-
|
|
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) === "
|
|
926
|
-
"tool with
|
|
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.
|
|
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",
|