@bismawy/pi-arnative 0.2.8 → 0.2.10
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 +16 -0
- package/README.md +12 -23
- package/extensions/footer.ts +83 -19
- package/extensions/section-headers.ts +46 -8
- package/extensions/tools.ts +35 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.10] - 2026-10-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- The working loader now shows elapsed turn time next to the label — `⠴ Working (50s)`, `⠴ Executing (1m 5s)`. One `setInterval(1s)` per turn (`extensions/footer.ts`), cleared on `turn_end`/`session_shutdown`; the suffix only appears from 1s (no `(0s)` flicker) and the timer keeps counting across tool switches inside a turn. With parallel tools the label follows the last started tool and only falls back to `Working` when every `toolCallId` has ended.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- Tool image previews (e.g. `read` on a PNG) no longer break the layout. pi renders a preview as an extra child below the tool box, padded with `result.rows - 1` blank rows, and the gap-stripping patch deleted exactly those rows — the terminal then painted the image over the rows underneath (chat text, editor, footer). Only the leading Spacer is dropped now; from the image sequence on, every line is kept.
|
|
10
|
+
|
|
11
|
+
## [0.2.9] - 2026-09-30
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- The startup `[Context]`, `[Skills]`, `[Extensions]` and `[Themes]` lists stacked under the header again on pi ≥ 0.99: pi replaced `ExpandableText`'s `getCollapsedText`/`getExpandedText` fields with a `build` callback plus a `state` object, so the section detector matched nothing and the tabs stayed at `[0]`. Both shapes are read now (`sectionBodyOf`), and the same applies to the `[Extension issues]` box, which read the stale `child.text` snapshot instead of the theme-aware `build()` output.
|
|
15
|
+
- Fixed NPM version badge in `README.md` to reference `@bismawy/pi-arnative` instead of placeholder `react`.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
3
19
|
## [0.2.8] - 2026-09-28
|
|
4
20
|
|
|
5
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Arnative
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Refined aesthetics. Cohesive tools. Built for Pi.
|
|
4
4
|
|
|
5
5
|
[](https://pi.dev/packages/@bismawy/pi-arnative)
|
|
6
|
-
[](https://www.npmjs.com/package/@bismawy/pi-arnative)
|
|
7
7
|
[](https://github.com/bismawy/pi-arnative)
|
|
8
8
|
|
|
9
9
|

|
|
@@ -35,32 +35,23 @@ pi --extension ./extensions/footer.ts
|
|
|
35
35
|
|
|
36
36
|
## Shortcuts
|
|
37
37
|
|
|
38
|
-
| Key /
|
|
38
|
+
| Key (Linux / Win) | Action |
|
|
39
39
|
| --- | --- |
|
|
40
|
+
| `ctrl+alt+t` · `alt+t` | Cycle header tabs |
|
|
41
|
+
| `ctrl+alt+r` · `alt+r` | Quick reload (`/reload`) |
|
|
42
|
+
| `ctrl+alt+n` · `alt+n` | New session (`/new`) — requires keybinding below |
|
|
40
43
|
| `/usage` | Show session usage & token metrics |
|
|
41
|
-
| `ctrl+alt+t` (`alt+t` on Windows/WSL) | Cycle header tabs (Directory → Model → Context → Skills → Extensions → Themes → Shortcut) |
|
|
42
|
-
| `ctrl+alt+n` (`alt+n` on Windows/WSL) | Start a new session (`/new`) — see [Keybindings](#keybindings) |
|
|
43
|
-
| `ctrl+alt+r` (`alt+r` on Windows/WSL) | Quick reload runtime (`/reload`) |
|
|
44
44
|
| Click tab | Open selected header tab |
|
|
45
45
|
| Click tool / `ctrl+e` | Toggle tool output box |
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
> **Notes:**
|
|
48
|
+
> - **Windows / WSL:** Windows Terminal aliases `Ctrl+Alt` to AltGr; use `alt+…` (or set `"altGrAliasing": false` in WT profile).
|
|
49
|
+
> - **macOS:** uses `ctrl+option+…` (plain Option composes special characters).
|
|
50
|
+
> - **Herdr / tmux glitch:** if shortcuts feel delayed or type stray letters, set `PI_TUI_ESC_TIMEOUT=100` in your shell profile.
|
|
48
51
|
|
|
49
|
-
###
|
|
52
|
+
### Keybindings
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
- **macOS:** only `ctrl+option+…` is registered — plain Option composes characters (`Option+r` = ®) unless “Use Option as Meta key” is on. Works in iTerm2 and Terminal.app as-is.
|
|
53
|
-
- **Linux desktops:** `ctrl+alt+t` is “launch terminal” on Ubuntu/Fedora GNOME, which eats the key before pi sees it. Rebind it there if you want tab cycling.
|
|
54
|
-
- **Multiplexers (Herdr/tmux) + slow PTYs:** every `ctrl+alt+…` / `alt+…` shortcut is sent as `ESC` followed by a byte. pi waits only ~10 ms for that second byte before treating a lone `ESC` as the Escape key — and an Escape mid-turn aborts it, leaving a stray `r`/`t`/`n` in the editor. That is why the shortcut can look “glitchy” and why it is intermittent. Raise the window with `PI_TUI_ESC_TIMEOUT=100` in the environment that launches pi (same knob pi auto-applies over SSH, at the cost of a ~100 ms delay before a real Escape registers):
|
|
55
|
-
|
|
56
|
-
```powershell
|
|
57
|
-
$env:PI_TUI_ESC_TIMEOUT = 100; pi
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Keybindings
|
|
61
|
-
|
|
62
|
-
`ctrl+alt+n` is a plain user keybinding, not a package shortcut — pi-arnative never registers it.
|
|
63
|
-
Add it once to `~/.pi/agent/keybindings.json` (the list form keeps Linux and Windows working):
|
|
54
|
+
Add to `~/.pi/agent/keybindings.json` to bind `app.session.new`:
|
|
64
55
|
|
|
65
56
|
```json
|
|
66
57
|
{
|
|
@@ -68,8 +59,6 @@ Add it once to `~/.pi/agent/keybindings.json` (the list form keeps Linux and Win
|
|
|
68
59
|
}
|
|
69
60
|
```
|
|
70
61
|
|
|
71
|
-
Restart pi to pick it up.
|
|
72
|
-
|
|
73
62
|
## Architecture
|
|
74
63
|
|
|
75
64
|
<details>
|
package/extensions/footer.ts
CHANGED
|
@@ -58,6 +58,23 @@ let assistantStartMs: number | null = null;
|
|
|
58
58
|
let assistantChars = 0;
|
|
59
59
|
let latestSpeed: number | null = null;
|
|
60
60
|
|
|
61
|
+
// Live elapsed clock for the working loader: "⠴ Working (50s)".
|
|
62
|
+
const WORK_TIMER_KEY = Symbol.for("pi-arnative.workTimer");
|
|
63
|
+
const WORK_LABEL = "Working";
|
|
64
|
+
let workVerb = WORK_LABEL;
|
|
65
|
+
let workStartMs: number | null = null;
|
|
66
|
+
let workUI: { setWorkingMessage?: (message?: string) => void } | null = null;
|
|
67
|
+
// Verbs of tools still running (toolCallId -> verb): the label must not reset to
|
|
68
|
+
// "Working" while a sibling tool is still going (pi executes tools in parallel).
|
|
69
|
+
const activeToolVerbs = new Map<string, string>();
|
|
70
|
+
|
|
71
|
+
/** Label while `active` tools remain: last started wins, empty -> default. */
|
|
72
|
+
export function currentToolVerb(active: ReadonlyMap<string, string>): string {
|
|
73
|
+
let verb = WORK_LABEL;
|
|
74
|
+
for (const v of active.values()) verb = v;
|
|
75
|
+
return verb;
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
function run(cmd: string, args: string[], cwd: string): Promise<string> {
|
|
62
79
|
return new Promise((resolve) => {
|
|
63
80
|
execFile(cmd, args, { cwd, timeout: 2000 }, (err, stdout) => {
|
|
@@ -233,9 +250,48 @@ function mcpStatusOf(sub: string): string {
|
|
|
233
250
|
return "Executing";
|
|
234
251
|
}
|
|
235
252
|
|
|
253
|
+
/** Loader label with the elapsed suffix; no suffix below 1s (avoids a "(0s)" flicker). */
|
|
254
|
+
export function workingLabel(verb: string, elapsedMs: number): string {
|
|
255
|
+
return elapsedMs >= 1000 ? `${verb} (${formatDuration(elapsedMs)})` : verb;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function paintWorking(): void {
|
|
259
|
+
if (!workUI || workStartMs === null) return;
|
|
260
|
+
try {
|
|
261
|
+
workUI.setWorkingMessage?.(workingLabel(workVerb, Date.now() - workStartMs));
|
|
262
|
+
} catch {
|
|
263
|
+
// ignore
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stopWorkingClock(): void {
|
|
268
|
+
const t = (globalThis as Record<symbol, any>)[WORK_TIMER_KEY];
|
|
269
|
+
if (t) clearInterval(t);
|
|
270
|
+
(globalThis as Record<symbol, any>)[WORK_TIMER_KEY] = null;
|
|
271
|
+
workStartMs = null;
|
|
272
|
+
activeToolVerbs.clear();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Start (or restart) the 1s tick from a fresh turn. */
|
|
276
|
+
function startWorkingClock(ctx: { ui?: any }): void {
|
|
277
|
+
stopWorkingClock();
|
|
278
|
+
workUI = ctx.ui ?? null;
|
|
279
|
+
workVerb = WORK_LABEL;
|
|
280
|
+
workStartMs = Date.now();
|
|
281
|
+
(globalThis as Record<symbol, any>)[WORK_TIMER_KEY] = setInterval(paintWorking, 1000);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Swap the verb (tool change) without resetting the turn clock. */
|
|
285
|
+
function setWorkingVerb(ctx: { ui?: any }, verb: string): void {
|
|
286
|
+
workUI = ctx.ui ?? null;
|
|
287
|
+
workVerb = verb;
|
|
288
|
+
if (workStartMs === null) workStartMs = Date.now();
|
|
289
|
+
paintWorking();
|
|
290
|
+
}
|
|
291
|
+
|
|
236
292
|
export function getToolWorkingMessage(toolName: string, args?: any): string {
|
|
237
293
|
const n = (toolName ?? "").trim();
|
|
238
|
-
if (!n) return
|
|
294
|
+
if (!n) return WORK_LABEL;
|
|
239
295
|
|
|
240
296
|
// Standard / file tools
|
|
241
297
|
if (n === "edit") return "Editing";
|
|
@@ -263,7 +319,7 @@ export function getToolWorkingMessage(toolName: string, args?: any): string {
|
|
|
263
319
|
return mcpStatusOf(sub);
|
|
264
320
|
}
|
|
265
321
|
|
|
266
|
-
return
|
|
322
|
+
return WORK_LABEL;
|
|
267
323
|
}
|
|
268
324
|
|
|
269
325
|
export default function (pi: ExtensionAPI) {
|
|
@@ -278,6 +334,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
278
334
|
});
|
|
279
335
|
|
|
280
336
|
pi.on("session_start", async (event, ctx) => {
|
|
337
|
+
// A /reload re-imports this module while the old instance's interval (and its
|
|
338
|
+
// ctx.ui closures) may still be alive; clear it before starting a new one.
|
|
339
|
+
stopWorkingClock();
|
|
281
340
|
if (event.reason !== "reload") {
|
|
282
341
|
sessionStartMs = Date.now();
|
|
283
342
|
(globalThis as Record<symbol, number>)[SESSION_START_KEY] = sessionStartMs;
|
|
@@ -502,29 +561,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
502
561
|
(globalThis as Record<symbol, any>)[MODEL_KEY] = currentModel;
|
|
503
562
|
(globalThis as Record<symbol, any>)[THINKING_KEY] = currentThinkingLevel;
|
|
504
563
|
(globalThis as Record<symbol, any>)[CWD_KEY] = ctx.cwd;
|
|
505
|
-
|
|
506
|
-
ctx.ui?.setWorkingMessage?.();
|
|
507
|
-
} catch {
|
|
508
|
-
// ignore
|
|
509
|
-
}
|
|
564
|
+
startWorkingClock(ctx);
|
|
510
565
|
void refreshGit(ctx.cwd).then(() => poke());
|
|
511
566
|
});
|
|
512
567
|
|
|
513
568
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
} catch {
|
|
518
|
-
// ignore
|
|
519
|
-
}
|
|
569
|
+
const verb = getToolWorkingMessage(event.toolName, event.args);
|
|
570
|
+
activeToolVerbs.set(event.toolCallId, verb);
|
|
571
|
+
setWorkingVerb(ctx, verb);
|
|
520
572
|
});
|
|
521
573
|
|
|
522
|
-
pi.on("tool_execution_end", async (
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
} catch {
|
|
526
|
-
// ignore
|
|
527
|
-
}
|
|
574
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
575
|
+
activeToolVerbs.delete(event.toolCallId);
|
|
576
|
+
setWorkingVerb(ctx, currentToolVerb(activeToolVerbs));
|
|
528
577
|
});
|
|
529
578
|
|
|
530
579
|
pi.on("message_update", async (event) => {
|
|
@@ -566,6 +615,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
566
615
|
});
|
|
567
616
|
|
|
568
617
|
pi.on("turn_end", async (_event, ctx) => {
|
|
618
|
+
stopWorkingClock();
|
|
569
619
|
try {
|
|
570
620
|
ctx.ui?.setWorkingMessage?.();
|
|
571
621
|
} catch {
|
|
@@ -587,6 +637,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
587
637
|
});
|
|
588
638
|
|
|
589
639
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
640
|
+
stopWorkingClock();
|
|
590
641
|
rerender = null;
|
|
591
642
|
const cur = ((globalThis as Record<symbol, number>)[GEN_KEY] ?? 0);
|
|
592
643
|
if (cur !== runtimeGen) return;
|
|
@@ -638,6 +689,19 @@ if (isMain(import.meta.url)) {
|
|
|
638
689
|
assert(getToolWorkingMessage("todo") === "Updating tasks", "todo -> Updating tasks");
|
|
639
690
|
assert(getToolWorkingMessage("unknown_tool") === "Working", "unknown -> Working");
|
|
640
691
|
assert(getToolWorkingMessage("mcp", { tool: "fetch_repo" }) === "Fetching", "mcp fetch -> Fetching");
|
|
692
|
+
|
|
693
|
+
// Self-check: working loader clock
|
|
694
|
+
assert(workingLabel("Working", 0) === "Working", "no elapsed suffix below 1s");
|
|
695
|
+
assert(workingLabel("Working", 999) === "Working", "no elapsed suffix at 999ms");
|
|
696
|
+
assert(workingLabel("Working", 50_000) === "Working (50s)", "elapsed seconds suffix");
|
|
697
|
+
assert(workingLabel("Executing", 65_000) === "Executing (1m 5s)", "elapsed minutes suffix");
|
|
698
|
+
assert(currentToolVerb(new Map()) === WORK_LABEL, "no active tool -> default label");
|
|
699
|
+
assert(currentToolVerb(new Map([["a", "Reading"], ["b", "Writing"]])) === "Writing", "parallel tools: last started wins");
|
|
700
|
+
assert(currentToolVerb(new Map([["a", "Reading"]])) === "Reading", "after one ends, the remaining tool's label shows");
|
|
701
|
+
startWorkingClock({ ui: { setWorkingMessage: () => {} } });
|
|
702
|
+
assert(Boolean((globalThis as Record<symbol, unknown>)[WORK_TIMER_KEY]), "clock starts: timer stored on globalThis");
|
|
703
|
+
stopWorkingClock();
|
|
704
|
+
assert((globalThis as Record<symbol, unknown>)[WORK_TIMER_KEY] == null, "clock stops: reload guard clears the timer");
|
|
641
705
|
assert(boxed.slice(0, 4).every((l) => visibleWidth(l) === visibleWidth(D) + 5), "uniform box width (sides + prompt)");
|
|
642
706
|
assert(boxEditorLines(["\u2500\u2500\u2500\u2500"], 1, plain).length === 1, "too narrow: left unchanged");
|
|
643
707
|
|
|
@@ -72,6 +72,28 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
|
|
|
72
72
|
return text;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Collapsed body of a pi startup section ("[Skills]\n a, b"). pi 0.99 dropped
|
|
77
|
+
* ExpandableText's own getCollapsedText/getExpandedText fields for a `build` callback
|
|
78
|
+
* plus a `state` object, so both shapes are read here and older pi keeps working.
|
|
79
|
+
* Returns null when the child is not an expandable section.
|
|
80
|
+
*/
|
|
81
|
+
export function sectionBodyOf(child: any): string | null {
|
|
82
|
+
if (typeof child?.getCollapsedText === "function") return String(child.getCollapsedText());
|
|
83
|
+
if (typeof child?.build === "function" && child?.state && typeof child.state === "object") {
|
|
84
|
+
return String(child.build());
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Text of a themed text line, whichever pi version built it (pi 0.99 defers it to `build`). */
|
|
90
|
+
export function themedTextOf(child: any): string {
|
|
91
|
+
if (typeof child?.build === "function") return String(child.build());
|
|
92
|
+
const direct = child?.text ?? child?.content;
|
|
93
|
+
if (typeof direct === "string" && direct !== "") return direct;
|
|
94
|
+
return typeof child?.getText === "function" ? String(child.getText()) : "";
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
export function sectionNameOf(text: string): "Context" | "Skills" | "Extensions" | "Themes" | null {
|
|
76
98
|
const nl = text.indexOf("\n");
|
|
77
99
|
const first = stripAnsi(nl === -1 ? text : text.slice(0, nl)).trim();
|
|
@@ -587,20 +609,19 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
|
|
|
587
609
|
const origAddChild = containerProto.addChild;
|
|
588
610
|
containerProto.addChild = function (child: any): unknown {
|
|
589
611
|
try {
|
|
590
|
-
const
|
|
612
|
+
const body = sectionBodyOf(child);
|
|
613
|
+
const sectionName = body === null ? null : sectionNameOf(body);
|
|
591
614
|
const isSection =
|
|
592
|
-
|
|
593
|
-
typeof child.getCollapsedText === "function" &&
|
|
594
|
-
typeof child.getExpandedText === "function" &&
|
|
615
|
+
body !== null &&
|
|
595
616
|
typeof child.setText === "function" &&
|
|
617
|
+
typeof child.render === "function" &&
|
|
596
618
|
(sectionName === null ? false : SECTION_ICONS[sectionName] !== undefined);
|
|
597
619
|
|
|
598
|
-
if (isSection && sectionName) {
|
|
620
|
+
if (isSection && sectionName && body !== null) {
|
|
599
621
|
// Mark this container as the loadedResourcesContainer
|
|
600
622
|
(this as Record<string, unknown>)._isLoadedResourcesContainer = true;
|
|
601
623
|
|
|
602
|
-
const
|
|
603
|
-
const rawBody = origCollapsed().split("\n").slice(1).join("\n");
|
|
624
|
+
const rawBody = body.split("\n").slice(1).join("\n");
|
|
604
625
|
const items = extractItemsFromBody(rawBody, sectionName === "Extensions");
|
|
605
626
|
tabStore.set(sectionName, items);
|
|
606
627
|
|
|
@@ -613,7 +634,7 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
|
|
|
613
634
|
}
|
|
614
635
|
} else if ((this as Record<string, unknown>)._isLoadedResourcesContainer) {
|
|
615
636
|
// pi core's "[Extension issues]" -> wrap in an arnative box.
|
|
616
|
-
const text =
|
|
637
|
+
const text = themedTextOf(child);
|
|
617
638
|
if (text.includes("[Extension issues]") && typeof child.render === "function") {
|
|
618
639
|
const origRender = child.render.bind(child);
|
|
619
640
|
child.render = (w: number) => renderBoxLines(activeThemeProxy, w, origRender(w), undefined, "warning");
|
|
@@ -893,5 +914,22 @@ if (isMain(import.meta.url)) {
|
|
|
893
914
|
assert(fakeSection.render(80).length === 0, "child of loadedResourcesContainer suppressed (render = [])");
|
|
894
915
|
assert(tabStore.get("Skills")?.includes("new-skill-1") === true, "new data lands in tabStore");
|
|
895
916
|
|
|
917
|
+
// pi 0.99 ExpandableText shape: `build` callback + `state` object, no getters.
|
|
918
|
+
const modernSection = {
|
|
919
|
+
state: { expanded: false },
|
|
920
|
+
build: () => "[Themes]\n arnative-sun, arnative-dusk",
|
|
921
|
+
setText: () => {},
|
|
922
|
+
render: (_w: number) => ["should be hidden"],
|
|
923
|
+
};
|
|
924
|
+
container.addChild(modernSection);
|
|
925
|
+
assert(modernSection.render(80).length === 0, "pi 0.99 section suppressed (build + state shape)");
|
|
926
|
+
assert(tabStore.get("Themes")?.includes("arnative-sun") === true, "pi 0.99 section data lands in tabStore");
|
|
927
|
+
|
|
928
|
+
// Startup help block is not a resource section and must survive untouched.
|
|
929
|
+
const helpBlock = { build: () => "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", state: { expanded: false }, render: () => ["help"] };
|
|
930
|
+
container.addChild(helpBlock);
|
|
931
|
+
assert(sectionBodyOf(helpBlock) === "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", "sectionBodyOf reads the build shape");
|
|
932
|
+
assert(sectionNameOf(sectionBodyOf(helpBlock)!) === null, "startup help block is not a resource section");
|
|
933
|
+
|
|
896
934
|
console.log("section-headers.ts self-check OK");
|
|
897
935
|
}
|
package/extensions/tools.ts
CHANGED
|
@@ -215,6 +215,23 @@ const OWN_BOX = new Set<string>();
|
|
|
215
215
|
const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; renderResult?: unknown } }): boolean =>
|
|
216
216
|
Boolean(self?.toolDefinition?.renderCall || self?.toolDefinition?.renderResult);
|
|
217
217
|
|
|
218
|
+
// Inline image line: kitty (\x1b_G) or iTerm2 (\x1b]1337;File=) graphics payload.
|
|
219
|
+
// pi renders a tool's image preview as an extra child BELOW the box, padding it with
|
|
220
|
+
// `result.rows - 1` blank rows; the last row carries a cursor-up prefix before the
|
|
221
|
+
// sequence. Not exported by pi-tui, so match both prefixes.
|
|
222
|
+
export const isImageLine = (line: string): boolean => line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
|
|
223
|
+
|
|
224
|
+
/** Drop the leading Spacer only when an image preview follows: its blank tail rows
|
|
225
|
+
* are the image's vertical space, and deleting them made the terminal draw the
|
|
226
|
+
* image over the rows below (chat text, editor, footer). */
|
|
227
|
+
export function stripEdgesKeepingImages(lines: string[]): string[] {
|
|
228
|
+
const firstImage = lines.findIndex(isImageLine);
|
|
229
|
+
if (firstImage < 0) return stripBlankEdges(lines);
|
|
230
|
+
let s = 0;
|
|
231
|
+
while (s < firstImage && isBlankLine(lines[s])) s++;
|
|
232
|
+
return lines.slice(s);
|
|
233
|
+
}
|
|
234
|
+
|
|
218
235
|
const GAP_KEY = Symbol.for("pi-arnative.toolGapStripped");
|
|
219
236
|
if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol, boolean>)[GAP_KEY]) {
|
|
220
237
|
(globalThis as Record<symbol, boolean>)[GAP_KEY] = true;
|
|
@@ -222,7 +239,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
222
239
|
ToolExecutionComponent.prototype.render = function (width: number): string[] {
|
|
223
240
|
const lines = origRender.call(this, width);
|
|
224
241
|
const name = (this as { toolName?: string }).toolName ?? "";
|
|
225
|
-
return OWN_BOX.has(name) || !hasOwnRendererDef(this) ?
|
|
242
|
+
return OWN_BOX.has(name) || !hasOwnRendererDef(this) ? stripEdgesKeepingImages(lines) : lines;
|
|
226
243
|
};
|
|
227
244
|
}
|
|
228
245
|
|
|
@@ -682,6 +699,23 @@ if (isMain(import.meta.url)) {
|
|
|
682
699
|
assert(stripBlankEdges(["", "\x1b[2m\x1b[22m", "x"]).join() === "x", "blank ANSI line counts as blank");
|
|
683
700
|
assert(stripBlankEdges(["a"]).join() === "a", "no blanks: kept intact");
|
|
684
701
|
|
|
702
|
+
// Image preview: pi pads the image with blank rows (`result.rows - 1`), and the last
|
|
703
|
+
// one carries a cursor-up prefix before the sequence. Those rows are the image's
|
|
704
|
+
// vertical space — deleting them made the terminal paint the image over whatever
|
|
705
|
+
// sits below (chat text, editor, footer). Regression: preview destroyed the layout.
|
|
706
|
+
const kittySeq = "\x1b[5A\x1b_Ga=T,f=100,i=7,q=2,C=1,m=0;AAAA\x1b\\";
|
|
707
|
+
const itermSeq = "\x1b]1337;File=inline=1:AAAA\x07";
|
|
708
|
+
const withImage = ["", "╭─╮", "│ ✓ read form_crop.png │", "╰─╯", "", kittySeq, "", ""];
|
|
709
|
+
const kept = stripEdgesKeepingImages(withImage);
|
|
710
|
+
assert(kept.length === 7, "image: only the leading gap dropped, blank tail kept");
|
|
711
|
+
assert(kept[0] === "╭─╮", "image: leading Spacer removed");
|
|
712
|
+
assert(kept.indexOf(kittySeq) === 4 && kept.length - 5 === 2, "image: rows after the sequence intact");
|
|
713
|
+
assert(
|
|
714
|
+
stripEdgesKeepingImages(["", "a", "", "b", " ", ""]).join() === "a,,b",
|
|
715
|
+
"no image: falls back to plain edge stripping",
|
|
716
|
+
);
|
|
717
|
+
assert(isImageLine(itermSeq) && !isImageLine("│ plain text │"), "image line detection: kitty + iTerm2 only");
|
|
718
|
+
|
|
685
719
|
// tools without their own renderer -> same box (memory_write etc.)
|
|
686
720
|
const noRenderer = { toolName: "memory_write", toolDefinition: {} };
|
|
687
721
|
// tool with no definition (e.g. "Tool glob not found") -> still a box, not plain text
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bismawy/pi-arnative",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Arnative look for Pi:
|
|
3
|
+
"version": "0.2.10",
|
|
4
|
+
"description": "Arnative look for Pi: warm-neutral themes, rounded tool boxes, tabbed header, compact footer, transcript clock, and a /usage dashboard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-theme",
|