pi-multimodal-proxy 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.21.0] - 2026-10-03
8
+
9
+ ### Added
10
+
11
+ - **Compact transcript rows via `pi.registerToolRenderer()` (Pi ≥ 1.0.1, feature-detected)** — `analyze_image` and `generate_image` calls now render as one compact, themed line each instead of the default JSON-args dump. The `analyze_image` call line shows the question (word-boundary truncated, surrogate-pair safe), image references (recall ids → `image="deadbeef…"`, file paths → file name, `+N more` beyond two shown), a crop summary (`bottom-right@0` / `25%,13% 50%×50%@1` / `128,64 256×256@0` / `crops ×N`), and a model override; `generate_image` shows the prompt and override. Result lines summarize `✓ provider/model · cached · 1.2s` for analyses and `✓ N images via provider/model · $0.0311 · image="<id8>…"` for generations, or `✗ <error>`; expanding a row reveals the full text content. Generated images still render inline from the result's image blocks (independent of `renderResult`), reliable across Kitty/Ghostty/WezTerm/Warp via pi 1.0.1's JPEG/WebP/GIF `Image` fixes.
12
+ - **Renderer coverage for unregistered tools** — registering through `registerToolRenderer` rather than inline `renderCall`/`renderResult` on the tool definitions means the compact rows also apply to calls replayed when the tool is *not registered* at render time: sessions resumed with the tool disabled (`/multimodal-proxy tool off`, mode off), transcript rows rendered before extension load, and HTML exports. Resolver chaining (`next() ?? {}`) preserves other extensions' renderers for the keys we don't set.
13
+ - **Tool-result `details` payloads** — both tools now return a display-sized `details` object (analyze: `ok`/`error`/`cached`/`provider`/`model`/`latencyMs`/`groundingFormat`, with the heavy `text`/`images` stripped; generate: the structured payload as-is — ids/dims/cost) that feeds the renderer and persists in the session log without ever reaching the model.
14
+ - Defensive renderer plumbing: pi-tui's `Text` (ANSI-aware wrapping) is loaded lazily with a width-truncating plain-lines fallback (ANSI-aware, rough wcwidth — pi's differential renderer stops on overwide lines) so an unresolvable package can never block extension load nor crash rendering; the expanded result view renders **every** text block, so a `generate_image` row on a text-only base model (where the `tool_result` hook replaces image blocks with description fences) still shows the per-image descriptions; renderers tolerate partial/garbage streaming args and missing result objects. (PR #37 review: CodeRabbit.)
15
+ - New `tool-render.test.ts` (24 tests: truncation with a surrogate-pair regression trap, ref/crop summarization, call and result lines, garbage tolerance, details stripping) plus a fake-host smoke script (`tools/smoke-renderers.mjs`, real ANSI theme) that loads the real extension module and exercises the resolver contract end-to-end — 540 tests total, all green; also verified loading under the real pi 1.0.1 RPC loader.
16
+
17
+ ### Changed
18
+
19
+ - Nothing user-visible on pi < 1.0.1 hosts: registration is gated on `typeof pi.registerToolRenderer === "function"`, so older runtimes keep the default tool rendering with zero behavior change.
20
+
7
21
  ## [1.20.0] - 2026-10-02
8
22
 
9
23
  ### Added
package/README.md CHANGED
@@ -8,6 +8,20 @@ When **video or audio files** are detected, they are routed to a **multimodal mo
8
8
 
9
9
  **YouTube links** are detected too: paste a URL (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`, etc.) and the video is downloaded with [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and analyzed exactly like a local file.
10
10
 
11
+ **Compact tool rows (Pi ≥ 1.0.1)** — `analyze_image` and `generate_image` calls render as one tidy line each (`analyze_image "What does the legend say?" · 2 images (image="deadbeef…", shot.png) · crop bottom-right@0`) with themed result summaries (`✓ zai/glm-5.3-flash · cached · 1.2s`, `✓ 1 image via google/imagen-4 · $0.0311`), instead of the default JSON-args dump. Generated images still render inline from the result blocks; expand a row to see the full analysis fence.
12
+
13
+ ## What's new in 1.21.0
14
+
15
+ Adopting Pi 1.0.1's new `pi.registerToolRenderer()` API:
16
+
17
+ - **Compact transcript rows for `analyze_image`** — the call line shows the question (truncated on a word boundary), the image refs (recall ids shortened to `image="deadbeef…"`, paths to their file name, `+N more` beyond two), a crop summary (`bottom-right@0`, `25%,13% 50%×50%@1`, `128,64 256×256@0`, or `crops ×N`), and any model override — instead of the raw JSON arguments. The result line summarizes `✓ provider/model · cached · latency` (or `✗ <error>`), and expanding the row reveals the full analysis fence.
18
+ - **Compact transcript rows for `generate_image`** — the call line shows the prompt (and any model override); the result line summarizes `✓ N images via provider/model · $cost · image="<id8>…"`. The generated images themselves keep rendering inline — pi draws image blocks from the result content next to the renderer output — so this is purely a better header/summary, now reliable across Kitty, Ghostty, WezTerm, and Warp thanks to pi 1.0.1's JPEG/WebP/GIF image fixes.
19
+ - **Works even when the tool isn't registered** — registering through `registerToolRenderer` (instead of inline `renderCall`/`renderResult` on the tool definitions) covers rows replayed from a session where the tool is now disabled (`/multimodal-proxy tool off`, mode off), resumed sessions, and HTML exports. `next()` chaining preserves renderers from other extensions for the keys we don't set.
20
+ - **Renderer state via tool `details`** — results now carry a display-sized `details` payload (provider/model, `cached`, `latencyMs`, image ids, `costUSD`; heavy fields stripped) that feeds the renderer but never reaches the model.
21
+ - Fully feature-gated (`typeof pi.registerToolRenderer === "function"`): pi < 1.0.1 hosts keep the default rendering, zero behavior change. pi-tui's `Text` component is loaded lazily with a plain-lines fallback so a missing package can never block extension load.
22
+
23
+ Also benefits from pi 1.0.1 host fixes (no code needed here): Anthropic prompt caches survive mid-session tool toggles (tools added/redefined mid-conversation are now defined inline), "model at capacity" errors are retried instead of ending the turn, and Bedrock Claude thinking-signature failures after tool/prompt changes are fixed.
24
+
11
25
  ## What's new in 1.20.0
12
26
 
13
27
  Image generation, riding Pi 1.0's new `models.generateImages()` API:
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Unit tests for the tool-transcript rendering helpers (1.21.0).
3
+ *
4
+ * These back the pi ≥ 1.0.1 registerToolRenderer() integration: compact
5
+ * analyze_image / generate_image call and result lines. Pure data in, plain
6
+ * segments out — theming happens in vision-proxy.ts.
7
+ *
8
+ * Run:
9
+ * node --experimental-strip-types --test extensions/__tests__/tool-render.test.ts
10
+ */
11
+
12
+ import { strict as assert } from "node:assert";
13
+ import { describe, it } from "node:test";
14
+ import {
15
+ analyzeDetailsForTranscript,
16
+ formatAnalyzeImageCall,
17
+ formatAnalyzeImageResult,
18
+ formatGenerateImageCall,
19
+ formatGenerateImageResult,
20
+ summarizeCrop,
21
+ summarizeForTranscript,
22
+ summarizeImageRef,
23
+ } from "../internal.ts";
24
+
25
+ const HASH32 = "deadbeefdeadbeefdeadbeefdeadbeef";
26
+
27
+ function plain(segments: ReadonlyArray<{ text: string }>): string {
28
+ return segments.map((s) => s.text).join("");
29
+ }
30
+
31
+ describe("summarizeForTranscript", () => {
32
+ it("passes short text through with collapsed whitespace", () => {
33
+ assert.equal(summarizeForTranscript(" hello world\n\tnext ", 50), "hello world next");
34
+ });
35
+
36
+ it("truncates long text on a word boundary", () => {
37
+ const out = summarizeForTranscript("alpha bravo charlie delta echo foxtrot golf hotel", 20);
38
+ assert.ok(out.length <= 20, `length ${out.length}: ${out}`);
39
+ assert.ok(out.endsWith("…"));
40
+ assert.ok(!out.endsWith(" …"), "no dangling space before ellipsis");
41
+ });
42
+
43
+ it("never splits a surrogate pair", () => {
44
+ const emoji = "😀".repeat(60); // each emoji is a surrogate pair
45
+ const out = summarizeForTranscript(emoji, 20); // odd max → cut lands mid-pair
46
+ assert.ok(out.length <= 20);
47
+ // No lone high surrogate anywhere: the string still round-trips as text.
48
+ // (With the guard removed, the hard cut leaves an unpaired high surrogate
49
+ // right before the ellipsis, which this regex detects.)
50
+ assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out));
51
+ });
52
+ });
53
+
54
+ describe("summarizeImageRef", () => {
55
+ it("shortens a quoted recall reference", () => {
56
+ assert.equal(summarizeImageRef(`image="${HASH32}"`), `image="deadbeef…"`);
57
+ });
58
+
59
+ it("shortens an unquoted recall reference and sha256:/#crop forms", () => {
60
+ assert.equal(summarizeImageRef(`image=${HASH32}`), `image="deadbeef…"`);
61
+ assert.equal(summarizeImageRef(`sha256:${HASH32}#x0y0`), `image="deadbeef…"`);
62
+ assert.equal(summarizeImageRef(HASH32), `image="deadbeef…"`);
63
+ });
64
+
65
+ it("reduces file paths to the file name (posix + windows)", () => {
66
+ assert.equal(summarizeImageRef("/home/u/pics/cat.png"), "cat.png");
67
+ assert.equal(summarizeImageRef("D:\\Users\\u\\Downloads\\shot (1).jpg"), "shot (1).jpg");
68
+ });
69
+
70
+ it("truncates very long file names", () => {
71
+ const long = "a".repeat(120) + ".png";
72
+ const out = summarizeImageRef(long);
73
+ assert.ok(out.length <= 48);
74
+ assert.ok(out.endsWith("…"));
75
+ });
76
+
77
+ it("leaves non-hash short junk as-is", () => {
78
+ assert.equal(summarizeImageRef("not-a-hash"), "not-a-hash");
79
+ });
80
+ });
81
+
82
+ describe("summarizeCrop", () => {
83
+ it("renders named regions with the image index", () => {
84
+ assert.equal(summarizeCrop({ image_index: 0, region: "bottom-right" }), "bottom-right@0");
85
+ });
86
+
87
+ it("renders normalized crops as percentages", () => {
88
+ assert.equal(
89
+ summarizeCrop({ image_index: 1, normalized: { x: 0.25, y: 0.133, width: 0.5, height: 0.5 } }),
90
+ "25%,13.3% 50%×50%@1",
91
+ );
92
+ });
93
+
94
+ it("renders pixel crops rounded", () => {
95
+ assert.equal(
96
+ summarizeCrop({ image_index: 0, pixels: { x: 128.4, y: 64.2, width: 256, height: 256 } }),
97
+ "128,64 256×256@0",
98
+ );
99
+ });
100
+
101
+ it("returns empty for unrecognized input", () => {
102
+ assert.equal(summarizeCrop(undefined), "");
103
+ assert.equal(summarizeCrop("nonsense"), "");
104
+ assert.equal(summarizeCrop({ image_index: 0 }), "crop@0");
105
+ });
106
+ });
107
+
108
+ describe("formatAnalyzeImageCall", () => {
109
+ it("builds a full call line", () => {
110
+ const segs = formatAnalyzeImageCall({
111
+ images: [`image="${HASH32}"`, "/tmp/chart.png"],
112
+ question: "What does the legend say?",
113
+ crop: [{ image_index: 0, region: "center" }],
114
+ model: "zai/glm-5.3-flash",
115
+ });
116
+ assert.equal(
117
+ plain(segs),
118
+ `"What does the legend say?" · 2 images (image="deadbeef…", chart.png) · crop center@0 · zai/glm-5.3-flash`,
119
+ );
120
+ assert.equal(segs[0]!.style, "accent");
121
+ });
122
+
123
+ it("collapses many images to two shown plus a count", () => {
124
+ const segs = formatAnalyzeImageCall({
125
+ images: ["/a.png", "/b.png", "/c.png", "/d.png"],
126
+ question: "q",
127
+ });
128
+ assert.ok(plain(segs).includes("4 images (a.png, b.png +2)"));
129
+ });
130
+
131
+ it("summarizes multiple crops as a count", () => {
132
+ const segs = formatAnalyzeImageCall({
133
+ images: ["/a.png", "/b.png"],
134
+ question: "q",
135
+ crop: [
136
+ { image_index: 0, region: "top" },
137
+ { image_index: 1, region: "bottom" },
138
+ ],
139
+ });
140
+ assert.ok(plain(segs).includes("crops ×2"));
141
+ });
142
+
143
+ it("truncates long questions and tolerates garbage args", () => {
144
+ const long = formatAnalyzeImageCall({ images: ["/a.png"], question: "x".repeat(300) });
145
+ assert.ok(plain(long).length < 200);
146
+
147
+ assert.deepEqual(formatAnalyzeImageCall("junk"), [{ text: '"junk"', style: "muted" }]);
148
+ assert.deepEqual(formatAnalyzeImageCall(null), [{ text: "null", style: "muted" }]);
149
+ });
150
+
151
+ it("handles missing question/images defensively", () => {
152
+ const segs = formatAnalyzeImageCall({});
153
+ assert.equal(plain(segs), '""');
154
+ });
155
+ });
156
+
157
+ describe("formatGenerateImageCall", () => {
158
+ it("builds prompt + model line", () => {
159
+ const segs = formatGenerateImageCall({ prompt: "A red panda astronaut, watercolor", model: "google/imagen-4" });
160
+ assert.equal(plain(segs), '"A red panda astronaut, watercolor" · google/imagen-4');
161
+ });
162
+
163
+ it("prompt-only line and garbage fallback", () => {
164
+ assert.equal(plain(formatGenerateImageCall({ prompt: "cat" })), '"cat"');
165
+ assert.deepEqual(formatGenerateImageCall(42), [{ text: "42", style: "muted" }]);
166
+ });
167
+ });
168
+
169
+ describe("formatAnalyzeImageResult", () => {
170
+ it("summarizes a successful analysis", () => {
171
+ const segs = formatAnalyzeImageResult(
172
+ { ok: true, provider: "zai", model: "glm-5.3-flash", cached: true, latencyMs: 1234 },
173
+ false,
174
+ );
175
+ assert.equal(plain(segs), "✓ zai/glm-5.3-flash · cached · 1.2s");
176
+ });
177
+
178
+ it("summarizes without optional fields and with model only", () => {
179
+ assert.equal(plain(formatAnalyzeImageResult({ ok: true }, false)), "✓");
180
+ assert.equal(plain(formatAnalyzeImageResult({ ok: true, model: "glm-5.3-flash" }, false)), "✓ glm-5.3-flash");
181
+ });
182
+
183
+ it("renders errors from details, isError flag, or a generic fallback", () => {
184
+ assert.equal(
185
+ plain(formatAnalyzeImageResult({ ok: false, error: "Error: consent required" }, true)),
186
+ "✗ Error: consent required",
187
+ );
188
+ // isError without details
189
+ assert.equal(plain(formatAnalyzeImageResult(undefined, true)), "✗ analysis failed");
190
+ // ok:false wins even without the flag (older rows)
191
+ assert.equal(plain(formatAnalyzeImageResult({ ok: false }, false)), "✗ analysis failed");
192
+ });
193
+
194
+ it("marks the summary segment styles", () => {
195
+ const segs = formatAnalyzeImageResult({ ok: true, provider: "zai", model: "m" }, false);
196
+ assert.equal(segs[0]!.style, "success");
197
+ assert.equal(segs[1]!.style, "muted");
198
+ });
199
+ });
200
+
201
+ describe("formatGenerateImageResult", () => {
202
+ it("summarizes generation with images, cost, and first id", () => {
203
+ const segs = formatGenerateImageResult(
204
+ {
205
+ ok: true,
206
+ provider: "google",
207
+ model: "imagen-4",
208
+ images: [
209
+ { id: HASH32, mimeType: "image/png", width: 1024, height: 768 },
210
+ { id: "f".repeat(32), mimeType: "image/png", width: 1024, height: 768 },
211
+ ],
212
+ costUSD: 0.0311,
213
+ },
214
+ false,
215
+ );
216
+ assert.equal(plain(segs), `✓ 2 images via google/imagen-4 · $0.0311 · image="deadbeef…" +1`);
217
+ });
218
+
219
+ it("renders errors from details or generically", () => {
220
+ assert.equal(
221
+ plain(formatGenerateImageResult({ ok: false, error: "Error: no keyed image model" }, true)),
222
+ "✗ Error: no keyed image model",
223
+ );
224
+ assert.equal(plain(formatGenerateImageResult(undefined, true)), "✗ generation failed");
225
+ });
226
+
227
+ it("falls back to 'done' when details carry no image list", () => {
228
+ assert.equal(plain(formatGenerateImageResult({ ok: true, provider: "google", model: "imagen-4" }, false)), "✓ done via google/imagen-4");
229
+ });
230
+ });
231
+
232
+ describe("analyzeDetailsForTranscript", () => {
233
+ it("strips text and images but keeps display fields", () => {
234
+ const out = analyzeDetailsForTranscript({
235
+ ok: true,
236
+ text: "a".repeat(2000),
237
+ images: [{ id: HASH32 }],
238
+ provider: "zai",
239
+ model: "glm-5.3-flash",
240
+ latencyMs: 120,
241
+ });
242
+ assert.deepEqual(out, { ok: true, provider: "zai", model: "glm-5.3-flash", latencyMs: 120 });
243
+ });
244
+
245
+ it("does not mutate the input", () => {
246
+ const input = { ok: true, text: "keep-me", images: [1, 2] };
247
+ analyzeDetailsForTranscript(input);
248
+ assert.deepEqual(input, { ok: true, text: "keep-me", images: [1, 2] });
249
+ });
250
+ });
@@ -3263,6 +3263,201 @@ export function buildToolCacheKey(
3263
3263
  return `${sortedHashes.join("+")}${cropSig ? "#crop:" + cropSig : ""}?q=${questionHash}&m=${modelId}`;
3264
3264
  }
3265
3265
 
3266
+ // ── Tool transcript rendering (pi ≥ 1.0.1 registerToolRenderer) ────────────
3267
+
3268
+ /**
3269
+ * Styling hints for one segment of a tool-transcript line, mapped to
3270
+ * theme.fg tokens by the renderer in vision-proxy.ts. Kept as plain data so
3271
+ * these helpers stay pure and unit-testable without a terminal theme.
3272
+ */
3273
+ export type TranscriptStyle = "plain" | "accent" | "muted" | "dim" | "success" | "warning" | "error";
3274
+
3275
+ export interface TranscriptSegment {
3276
+ text: string;
3277
+ style: TranscriptStyle;
3278
+ }
3279
+
3280
+ /** Collapse whitespace and truncate on a word boundary (surrogate-pair safe). */
3281
+ export function summarizeForTranscript(text: string, max: number): string {
3282
+ const t = text.replace(/\s+/g, " ").trim();
3283
+ if (t.length <= max) return t;
3284
+ let cut = t.slice(0, Math.max(1, max - 1));
3285
+ // Never split a surrogate pair on a hard cut (lone high surrogate → U+FFFD).
3286
+ if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
3287
+ const ws = cut.lastIndexOf(" ");
3288
+ if (ws > cut.length * 0.6) cut = cut.slice(0, ws);
3289
+ return `${cut.trimEnd()}…`;
3290
+ }
3291
+
3292
+ /** Defensive finite-number check for values coming from model-authored args. */
3293
+ function isFiniteNumber(v: unknown): v is number {
3294
+ return typeof v === "number" && Number.isFinite(v);
3295
+ }
3296
+
3297
+ /** Compact percentage for crop summaries: integral values stay integral. */
3298
+ function pct(v: number): string {
3299
+ const p = v * 100;
3300
+ return Math.abs(p - Math.round(p)) < 0.05 ? String(Math.round(p)) : String(Math.round(p * 10) / 10);
3301
+ }
3302
+
3303
+ /** Safe one-line JSON rendering of an arbitrary value (circular-safe). */
3304
+ function safeJson(value: unknown, max = 120): string {
3305
+ try {
3306
+ return summarizeForTranscript(JSON.stringify(value) ?? String(value), max);
3307
+ } catch {
3308
+ return String(value);
3309
+ }
3310
+ }
3311
+
3312
+ /**
3313
+ * Shorten one analyze_image image reference for display: a recall id
3314
+ * (`image="<hash>"`, `sha256:<hash>#…`, or a bare hash) becomes
3315
+ * `image="deadbeef…"`; a file path becomes its file name (Windows or POSIX).
3316
+ */
3317
+ export function summarizeImageRef(ref: string): string {
3318
+ const s = ref.trim().replace(/^["']|["']$/g, "");
3319
+ const quoted = /image="?([a-f0-9]{16,64})"?/i.exec(s);
3320
+ if (quoted) return `image="${quoted[1]!.slice(0, 8)}…"`;
3321
+ const bare = parseRecallRef(s);
3322
+ if (bare) return `image="${bare.slice(0, 8)}…"`;
3323
+ const base = s.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? s;
3324
+ return summarizeForTranscript(base, 48);
3325
+ }
3326
+
3327
+ /**
3328
+ * Compact crop descriptor for the transcript: `bottom-right@0`,
3329
+ * `25%,13% 50%×50%@1`, or `128,64 256×256@0`. Empty string when nothing
3330
+ * recognizable.
3331
+ */
3332
+ export function summarizeCrop(crop: unknown): string {
3333
+ if (typeof crop !== "object" || crop === null) return "";
3334
+ const c = crop as Record<string, unknown>;
3335
+ const idx =
3336
+ isFiniteNumber(c.image_index) ? `@${Math.trunc(c.image_index)}` : "";
3337
+ if (typeof c.region === "string" && c.region.trim()) return `${c.region.trim()}${idx}`;
3338
+ const box = (c.normalized ?? c.pixels) as Record<string, unknown> | undefined;
3339
+ if (
3340
+ typeof box === "object" &&
3341
+ box !== null &&
3342
+ isFiniteNumber(box.x) &&
3343
+ isFiniteNumber(box.y) &&
3344
+ isFiniteNumber(box.width) &&
3345
+ isFiniteNumber(box.height)
3346
+ ) {
3347
+ if (c.normalized !== undefined) {
3348
+ return `${pct(box.x)}%,${pct(box.y)}% ${pct(box.width)}%×${pct(box.height)}%${idx}`;
3349
+ }
3350
+ return `${Math.round(box.x)},${Math.round(box.y)} ${Math.round(box.width)}×${Math.round(box.height)}${idx}`;
3351
+ }
3352
+ return idx ? `crop${idx}` : "";
3353
+ }
3354
+
3355
+ /** One-line transcript summary of an analyze_image call (pi ≥ 1.0.1 renderer). */
3356
+ export function formatAnalyzeImageCall(args: unknown): TranscriptSegment[] {
3357
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
3358
+ return [{ text: safeJson(args), style: "muted" }];
3359
+ }
3360
+ const a = args as Record<string, unknown>;
3361
+ const out: TranscriptSegment[] = [];
3362
+ const question = typeof a.question === "string" ? a.question : "";
3363
+ out.push({ text: `"${summarizeForTranscript(question, 120)}"`, style: "accent" });
3364
+ const images = Array.isArray(a.images)
3365
+ ? a.images.filter((r): r is string => typeof r === "string" && r.trim() !== "")
3366
+ : [];
3367
+ if (images.length > 0) {
3368
+ const shown = images.slice(0, 2).map(summarizeImageRef);
3369
+ const more = images.length - shown.length;
3370
+ out.push({
3371
+ text:
3372
+ ` · ${images.length} image${images.length === 1 ? "" : "s"}` +
3373
+ (shown.length ? ` (${shown.join(", ")}${more > 0 ? ` +${more}` : ""})` : ""),
3374
+ style: "muted",
3375
+ });
3376
+ }
3377
+ const crops = Array.isArray(a.crop) ? a.crop.map(summarizeCrop).filter((s) => s !== "") : [];
3378
+ if (crops.length === 1) out.push({ text: ` · crop ${crops[0]}`, style: "dim" });
3379
+ else if (crops.length > 1) out.push({ text: ` · crops ×${crops.length}`, style: "dim" });
3380
+ const model = typeof a.model === "string" && a.model.trim() ? a.model.trim() : undefined;
3381
+ if (model) out.push({ text: ` · ${summarizeForTranscript(model, 60)}`, style: "muted" });
3382
+ return out;
3383
+ }
3384
+
3385
+ /** One-line transcript summary of a generate_image call (pi ≥ 1.0.1 renderer). */
3386
+ export function formatGenerateImageCall(args: unknown): TranscriptSegment[] {
3387
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
3388
+ return [{ text: safeJson(args), style: "muted" }];
3389
+ }
3390
+ const a = args as Record<string, unknown>;
3391
+ const prompt = typeof a.prompt === "string" ? a.prompt : "";
3392
+ const out: TranscriptSegment[] = [{ text: `"${summarizeForTranscript(prompt, 120)}"`, style: "accent" }];
3393
+ const model = typeof a.model === "string" && a.model.trim() ? a.model.trim() : undefined;
3394
+ if (model) out.push({ text: ` · ${summarizeForTranscript(model, 60)}`, style: "muted" });
3395
+ return out;
3396
+ }
3397
+
3398
+ function recordOf(details: unknown): Record<string, unknown> | undefined {
3399
+ return typeof details === "object" && details !== null && !Array.isArray(details)
3400
+ ? (details as Record<string, unknown>)
3401
+ : undefined;
3402
+ }
3403
+
3404
+ /** One-line transcript summary of an analyze_image result from its `details`. */
3405
+ export function formatAnalyzeImageResult(details: unknown, isError: boolean): TranscriptSegment[] {
3406
+ const d = recordOf(details);
3407
+ if (isError || d?.ok === false) {
3408
+ const msg = typeof d?.error === "string" && d.error ? d.error : "analysis failed";
3409
+ return [{ text: `✗ ${summarizeForTranscript(msg, 160)}`, style: "error" }];
3410
+ }
3411
+ const segments: TranscriptSegment[] = [{ text: "✓", style: "success" }];
3412
+ const provider = typeof d?.provider === "string" && d.provider ? d.provider : undefined;
3413
+ const modelId = typeof d?.model === "string" && d.model ? d.model : undefined;
3414
+ if (provider && modelId) segments.push({ text: ` ${summarizeForTranscript(`${provider}/${modelId}`, 80)}`, style: "muted" });
3415
+ else if (modelId) segments.push({ text: ` ${summarizeForTranscript(modelId, 80)}`, style: "muted" });
3416
+ if (d?.cached === true) segments.push({ text: " · cached", style: "dim" });
3417
+ if (isFiniteNumber(d?.latencyMs)) {
3418
+ segments.push({ text: ` · ${(Math.max(0, d.latencyMs) / 1000).toFixed(1)}s`, style: "dim" });
3419
+ }
3420
+ return segments;
3421
+ }
3422
+
3423
+ /** One-line transcript summary of a generate_image result from its `details`. */
3424
+ export function formatGenerateImageResult(details: unknown, isError: boolean): TranscriptSegment[] {
3425
+ const d = recordOf(details);
3426
+ if (isError || d?.ok === false) {
3427
+ const msg = typeof d?.error === "string" && d.error ? d.error : "generation failed";
3428
+ return [{ text: `✗ ${summarizeForTranscript(msg, 160)}`, style: "error" }];
3429
+ }
3430
+ const images = Array.isArray(d?.images)
3431
+ ? (d!.images as unknown[]).filter((x): x is Record<string, unknown> => typeof x === "object" && x !== null)
3432
+ : [];
3433
+ const provider = typeof d?.provider === "string" && d.provider ? d.provider : undefined;
3434
+ const modelId = typeof d?.model === "string" && d.model ? d.model : undefined;
3435
+ const count = images.length > 0 ? `${images.length} image${images.length === 1 ? "" : "s"}` : "done";
3436
+ const segments: TranscriptSegment[] =
3437
+ provider && modelId
3438
+ ? [{ text: `✓ ${count} via ${summarizeForTranscript(`${provider}/${modelId}`, 80)}`, style: "success" }]
3439
+ : [{ text: `✓ ${count}`, style: "success" }];
3440
+ if (isFiniteNumber(d?.costUSD)) segments.push({ text: ` · $${Math.max(0, d.costUSD).toFixed(4)}`, style: "dim" });
3441
+ const firstId = typeof images[0]?.id === "string" && images[0].id ? images[0].id : undefined;
3442
+ if (firstId) {
3443
+ const more = images.length > 1 ? ` +${images.length - 1}` : "";
3444
+ segments.push({ text: ` · image="${firstId.slice(0, 8)}…"${more}`, style: "dim" });
3445
+ }
3446
+ return segments;
3447
+ }
3448
+
3449
+ /**
3450
+ * Copy of a structured analyze_image result without the fields the
3451
+ * transcript renderer never reads — `details` must stay small because it is
3452
+ * persisted in the session log alongside the (already stored) text fence.
3453
+ */
3454
+ export function analyzeDetailsForTranscript(structured: Record<string, unknown>): Record<string, unknown> {
3455
+ const rest: Record<string, unknown> = { ...structured };
3456
+ delete rest.text;
3457
+ delete rest.images;
3458
+ return rest;
3459
+ }
3460
+
3266
3461
  // ── Fence builders ────────────────────────────────────────────────────────
3267
3462
 
3268
3463
  export interface VideoDescriptionEntry {
@@ -256,6 +256,12 @@ import {
256
256
  buildVideoProxySection,
257
257
  extractXaiResponsesText,
258
258
  formatXaiSttTranscript,
259
+ analyzeDetailsForTranscript,
260
+ formatAnalyzeImageCall,
261
+ formatAnalyzeImageResult,
262
+ formatGenerateImageCall,
263
+ formatGenerateImageResult,
264
+ type TranscriptSegment,
259
265
  isTranscriptionRequest,
260
266
  isXaiProvider,
261
267
  bufferToPiAiImage,
@@ -2381,6 +2387,227 @@ export default function (pi: ExtensionAPI) {
2381
2387
  );
2382
2388
  }
2383
2389
 
2390
+ // ── Tool transcript renderers (pi ≥ 1.0.1) ─────────────────────────────
2391
+
2392
+ /** Minimal structural shape of the TUI component the renderers return. */
2393
+ interface ComponentLike {
2394
+ render(width: number): string[];
2395
+ invalidate?(): void;
2396
+ }
2397
+
2398
+ /** Theme subset the renderers use (pi's Theme helpers). */
2399
+ interface RenderThemeLike {
2400
+ fg(token: string, text: string): string;
2401
+ bold(text: string): string;
2402
+ }
2403
+
2404
+ /** ToolRenderContext subset available to renderCall/renderResult. */
2405
+ interface RenderContextLike {
2406
+ isError?: boolean;
2407
+ isPartial?: boolean;
2408
+ expanded?: boolean;
2409
+ showImages?: boolean;
2410
+ }
2411
+
2412
+ /** AgentToolResult subset the result renderer receives. */
2413
+ interface RenderResultLike {
2414
+ content?: ReadonlyArray<{ type?: string; text?: string }>;
2415
+ details?: unknown;
2416
+ }
2417
+
2418
+ type ToolRenderersLike = {
2419
+ renderCall?: (args: unknown, theme: RenderThemeLike, context: RenderContextLike) => ComponentLike;
2420
+ renderResult?: (
2421
+ result: RenderResultLike,
2422
+ options: { expanded: boolean; isPartial: boolean },
2423
+ theme: RenderThemeLike,
2424
+ context: RenderContextLike,
2425
+ ) => ComponentLike;
2426
+ };
2427
+
2428
+ /**
2429
+ * pi-tui's Text (ANSI-aware word wrap), loaded lazily so an unresolvable
2430
+ * package can never block extension load — the crash-loop lesson from
2431
+ * 1.16.0. Warmed at registration time, long before the first tool row
2432
+ * renders.
2433
+ */
2434
+ let _textCtor: (new (text: string, paddingX?: number, paddingY?: number) => ComponentLike) | undefined;
2435
+ let _textLoadTried = false;
2436
+
2437
+ function preloadTextComponent(): void {
2438
+ if (_textLoadTried) return;
2439
+ _textLoadTried = true;
2440
+ void import("@earendil-works/pi-tui")
2441
+ .then((m) => {
2442
+ const ctor = (
2443
+ m as { Text?: new (text: string, paddingX?: number, paddingY?: number) => ComponentLike }
2444
+ ).Text;
2445
+ if (ctor) _textCtor = ctor;
2446
+ })
2447
+ .catch(() => {
2448
+ // pi-tui unreachable — the plain-lines fallback below keeps rendering
2449
+ });
2450
+ }
2451
+
2452
+ /**
2453
+ * Fixed-lines component used only when pi-tui's Text cannot load.
2454
+ * Truncates each line to the render width (ANSI-aware, rough wcwidth) —
2455
+ * pi's differential renderer stops on "rendered line exceeds terminal
2456
+ * width", so an overwide fallback line is worse than an ugly one.
2457
+ */
2458
+ function plainLines(text: string): ComponentLike {
2459
+ return {
2460
+ render: (width: number) => text.split("\n").map((line) => truncateAnsiLine(line, Math.max(1, width))),
2461
+ invalidate: () => {},
2462
+ };
2463
+ }
2464
+
2465
+ /** Rough wcwidth: 2 for the common wide ranges, 0 for combining marks, else 1. */
2466
+ function codePointWidth(cp: number): number {
2467
+ if (cp >= 0x0300 && cp <= 0x036f) return 0; // combining diacritics
2468
+ if (
2469
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
2470
+ (cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals … Yi
2471
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
2472
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat ideographs
2473
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
2474
+ (cp >= 0xff00 && cp <= 0xff60) ||
2475
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
2476
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji blocks
2477
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK ext
2478
+ ) {
2479
+ return 2;
2480
+ }
2481
+ return 1;
2482
+ }
2483
+
2484
+ /** ANSI-preserving truncation of one styled line to a visible width. */
2485
+ function truncateAnsiLine(line: string, width: number): string {
2486
+ let out = "";
2487
+ let w = 0;
2488
+ let i = 0;
2489
+ while (i < line.length) {
2490
+ if (line[i] === "\x1b") {
2491
+ const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
2492
+ const seq = m ? m[0] : line[i]!;
2493
+ out += seq;
2494
+ i += seq.length;
2495
+ continue;
2496
+ }
2497
+ const cp = line.codePointAt(i)!;
2498
+ const s = String.fromCodePoint(cp);
2499
+ const cw = codePointWidth(cp);
2500
+ if (w + cw > width) break;
2501
+ out += s;
2502
+ w += cw;
2503
+ i += s.length;
2504
+ }
2505
+ return out;
2506
+ }
2507
+
2508
+ function textComponent(text: string): ComponentLike {
2509
+ return _textCtor ? new _textCtor(text, 0, 0) : plainLines(text);
2510
+ }
2511
+
2512
+ function styleSegments(segments: readonly TranscriptSegment[], theme: RenderThemeLike): string {
2513
+ let out = "";
2514
+ for (const seg of segments) {
2515
+ out += seg.style === "plain" ? seg.text : theme.fg(seg.style, seg.text);
2516
+ }
2517
+ return out;
2518
+ }
2519
+
2520
+ function textBlocks(content: ReadonlyArray<{ type?: string; text?: string }> | undefined): string[] {
2521
+ if (!content) return [];
2522
+ const out: string[] = [];
2523
+ for (const block of content) {
2524
+ if (block?.type === "text" && typeof block.text === "string" && block.text) out.push(block.text);
2525
+ }
2526
+ return out;
2527
+ }
2528
+
2529
+ function toolTitle(name: string, theme: RenderThemeLike): string {
2530
+ return theme.fg("toolTitle", theme.bold(`${name} `));
2531
+ }
2532
+
2533
+ /** Compact result component: summary line, plus the full text content when expanded. */
2534
+ function resultComponent(
2535
+ summary: readonly TranscriptSegment[],
2536
+ result: RenderResultLike,
2537
+ options: { expanded: boolean },
2538
+ theme: RenderThemeLike,
2539
+ ): ComponentLike {
2540
+ let text = styleSegments(summary, theme);
2541
+ if (options.expanded) {
2542
+ // All text blocks: a generate_image result on a text-only base model
2543
+ // carries the header plus per-image description fences that replaced
2544
+ // the image blocks — the first block alone would hide them.
2545
+ const body = textBlocks(result.content).join("\n\n");
2546
+ if (body) {
2547
+ // Re-apply the style per line: pi resets styling after every line.
2548
+ text += `\n${body.split("\n").map((line) => theme.fg("toolOutput", line)).join("\n")}`;
2549
+ }
2550
+ }
2551
+ return textComponent(text);
2552
+ }
2553
+
2554
+ /**
2555
+ * Register transcript renderers for analyze_image/generate_image (pi ≥
2556
+ * 1.0.1). Registered through pi.registerToolRenderer — instead of inline
2557
+ * renderCall/renderResult on the tool definitions — so the compact rows
2558
+ * also cover tools that are *not registered* at render time: calls
2559
+ * replayed from a session where the tool is now disabled (`tool off` /
2560
+ * mode off), resumed sessions rendered before extension load, and HTML
2561
+ * exports. `next() ?? {}` keeps renderers from later resolvers / the
2562
+ * registered tool for the keys we don't set. Generated images keep
2563
+ * rendering inline regardless: pi draws image blocks from the result
2564
+ * content next to the renderer output, independent of renderResult.
2565
+ */
2566
+ function registerToolRenderers() {
2567
+ const api = pi as unknown as {
2568
+ registerToolRenderer?: (
2569
+ resolver: (toolName: string, next: () => ToolRenderersLike | undefined) => ToolRenderersLike | undefined,
2570
+ ) => void;
2571
+ };
2572
+ if (typeof api.registerToolRenderer !== "function") return; // pi < 1.0.1
2573
+ preloadTextComponent();
2574
+ api.registerToolRenderer((toolName, next) => {
2575
+ if (toolName === "analyze_image") {
2576
+ return {
2577
+ ...(next() ?? {}),
2578
+ renderCall: (args, theme) =>
2579
+ textComponent(toolTitle("analyze_image", theme) + styleSegments(formatAnalyzeImageCall(args), theme)),
2580
+ renderResult: (result, options, theme, context) => {
2581
+ if (options.isPartial) return textComponent(theme.fg("warning", "Analyzing…"));
2582
+ return resultComponent(
2583
+ formatAnalyzeImageResult(result?.details, context?.isError === true),
2584
+ result ?? {},
2585
+ options,
2586
+ theme,
2587
+ );
2588
+ },
2589
+ };
2590
+ }
2591
+ if (toolName === "generate_image") {
2592
+ return {
2593
+ ...(next() ?? {}),
2594
+ renderCall: (args, theme) =>
2595
+ textComponent(toolTitle("generate_image", theme) + styleSegments(formatGenerateImageCall(args), theme)),
2596
+ renderResult: (result, options, theme, context) => {
2597
+ if (options.isPartial) return textComponent(theme.fg("warning", "Generating…"));
2598
+ return resultComponent(
2599
+ formatGenerateImageResult(result?.details, context?.isError === true),
2600
+ result ?? {},
2601
+ options,
2602
+ theme,
2603
+ );
2604
+ },
2605
+ };
2606
+ }
2607
+ return next();
2608
+ });
2609
+ }
2610
+
2384
2611
  // ── Virtual model (pi ≥ 0.99) ─────────────────────────────────────────────
2385
2612
 
2386
2613
  const VIRTUAL_PROVIDER = "multimodal-proxy";
@@ -2571,6 +2798,10 @@ export default function (pi: ExtensionAPI) {
2571
2798
  // unreadable config file — session_start surfaces the error path
2572
2799
  });
2573
2800
 
2801
+ // Transcript renderers for analyze_image/generate_image rows (pi ≥ 1.0.1).
2802
+ // Registered once at load; the resolver serves any later session state.
2803
+ registerToolRenderers();
2804
+
2574
2805
  /** Register or unregister the analyze_image tool based on config. */
2575
2806
  function syncToolRegistration(config: VisionConfig, runtimeCtx?: ExtensionContext) {
2576
2807
  const shouldHaveTool = config.mode !== "off" && config.tool === "on";
@@ -2630,6 +2861,9 @@ export default function (pi: ExtensionAPI) {
2630
2861
  // codemode scripts. Older hosts ignore both fields.
2631
2862
  ...(outcome.isError ? { isError: true } : {}),
2632
2863
  ...(outcome.structured ? { structuredContent: outcome.structured } : {}),
2864
+ // `details` feeds the transcript renderer (pi ≥ 1.0.1) without
2865
+ // the model seeing it; heavy fields (text, images) are stripped.
2866
+ ...(outcome.structured ? { details: analyzeDetailsForTranscript(outcome.structured) } : {}),
2633
2867
  };
2634
2868
  },
2635
2869
  });
@@ -2705,6 +2939,9 @@ export default function (pi: ExtensionAPI) {
2705
2939
  content: outcome.content,
2706
2940
  ...(outcome.isError ? { isError: true } : {}),
2707
2941
  ...(outcome.structured ? { structuredContent: outcome.structured } : {}),
2942
+ // `details` feeds the transcript renderer (pi ≥ 1.0.1); the
2943
+ // structured payload is already display-sized (ids, dims, cost).
2944
+ ...(outcome.structured ? { details: outcome.structured } : {}),
2708
2945
  ...(outcome.usage !== undefined ? { usage: outcome.usage } : {}),
2709
2946
  };
2710
2947
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Automatic image, video and audio description for any model in Pi. Routes media to a multimodal model and injects descriptions into context.",
5
5
  "keywords": [
6
6
  "pi-package"