pi-multimodal-proxy 1.20.0 → 1.22.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,29 @@ 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.22.0] - 2026-10-10
8
+
9
+ ### Added
10
+
11
+ - **Claude Haiku 5.5 in the implicit-model candidate chain** — `DEFAULT_MODEL_FALLBACKS` now reads `zai/glm-5.3-flash` → `deepseek/deepseek-v4-flash-vision-exp` → `anthropic/claude-haiku-5-5` (Pi ≥ 1.1.0 catalog) → `anthropic/claude-sonnet-5` → `anthropic/claude-sonnet-4-5`. An anthropic-keyed setup whose cheaper candidates are unkeyed lands on Haiku 5.5 ($0.1/M in, $0.5/M out, vision-capable, 1M context; above 100K input tokens $0.5/$2.5 — output still 4× cheaper than Sonnet's flat rate) instead of Sonnet 5 ($2/M in, $10/M out) — 20× cheaper output at typical description-prompt sizes, same API key. Description prompts (a few images + instructions) sit far below the tier threshold. Haiku's catalog `inputLimits.images.resize` (2000 px / q80) feeds the existing model-aware upload downscale. Pre-1.1.0 catalogs simply don't contain Haiku, so the registry check skips it and behavior is unchanged; explicit model choices are never rewritten.
12
+ - **`durationMs` fallback in the `analyze_image` result row (Pi ≥ 1.1.0, feature-detected)** — the pi 1.1.0 tool render context carries `durationMs`, the monotonic-clock `execute()` time recorded with the final result. `formatAnalyzeImageResult` accepts it as a display fallback when `details` carry no `latencyMs`, so rows replayed from foreign sessions, HTML exports, and results recorded before `details` existed still render `✓ … · 1.2s`. `details.latencyMs` (the vision call itself, also reported to codemode scripts via `structuredContent`) always wins — the host duration covers consent checks and crop encoding too, so the two measure different things. Older hosts pass no `durationMs` and rendering is unchanged.
13
+ - **Per-invocation tool toggling documented** — pi 1.1.0's `--tools +name/-name` entries change the default tool selection instead of replacing it. README now shows `pi -t -analyze_image` (one-shot session without the re-query tool) and `pi -t +analyze_image` (add it back to a trimmed selection) alongside the existing persistent toggles (`/multimodal-proxy tool off`, `PI_VISION_PROXY_TOOL=off`). Privacy section updated to spell out the full current default-model chain.
14
+ - Six new tests (546 total, all green): Haiku placement/ordering in the chain, key-aware preference of Haiku over Sonnet, catalog-without-Haiku skip, `durationMs` fallback/preference/finite-guard in the result formatter. Syntax check clean (8 files).
15
+
16
+ ## [1.21.0] - 2026-10-03
17
+
18
+ ### Added
19
+
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+ - 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.)
24
+ - 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.
25
+
26
+ ### Changed
27
+
28
+ - 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.
29
+
7
30
  ## [1.20.0] - 2026-10-02
8
31
 
9
32
  ### Added
package/README.md CHANGED
@@ -8,6 +8,28 @@ 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.22.0
14
+
15
+ Borrowing from Pi 1.1.0:
16
+
17
+ - **Claude Haiku 5.5 joins the implicit-model candidate chain** — `zai/glm-5.3-flash` → `deepseek/deepseek-v4-flash-vision-exp` → `anthropic/claude-haiku-5-5` (new, Pi ≥ 1.1.0) → `anthropic/claude-sonnet-5` → `anthropic/claude-sonnet-4-5`. When your cheaper candidates have no API key, an anthropic-keyed setup now lands on Haiku 5.5 instead of Sonnet 5 for image descriptions — $0.1/M in, $0.5/M out vs $2/M, $10/M (20× cheaper output at typical description-prompt sizes; above 100K input tokens Haiku is $0.5/$2.5, still 4× cheaper output). Same key, still vision-capable, and its catalog `inputLimits.images.resize` feeds the model-aware upload downscale. Catalogs without Haiku (pi < 1.1.0) skip it and behave exactly as before; explicit choices are never rewritten.
18
+ - **Result rows show a duration even without `details`** — pi 1.1.0's render context carries `durationMs` (the recorded `execute()` time of a final tool result). The `analyze_image` result line now falls back to it when its `details` have no `latencyMs`, so rows replayed from foreign/HTML-exported sessions still show `✓ … · 1.2s`. Our own `latencyMs` (the vision call itself, reported to codemode scripts via `structuredContent`) always wins.
19
+ - **Per-invocation tool toggling documented** — pi 1.1.0's `--tools +name/-name` entries modify the default tool selection instead of replacing it, so `pi -t -analyze_image` runs a one-shot session without the re-query tool while keeping everything else (see [Slash commands](#slash-commands)).
20
+
21
+ ## What's new in 1.21.0
22
+
23
+ Adopting Pi 1.0.1's new `pi.registerToolRenderer()` API:
24
+
25
+ - **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.
26
+ - **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.
27
+ - **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.
28
+ - **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.
29
+ - 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.
30
+
31
+ 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.
32
+
11
33
  ## What's new in 1.20.0
12
34
 
13
35
  Image generation, riding Pi 1.0's new `models.generateImages()` API:
@@ -235,6 +257,8 @@ Legacy alias: /vision-proxy <args> works identically.
235
257
 
236
258
  When an env var is set, the matching `/multimodal-proxy` subcommand is locked.
237
259
 
260
+ For one-shot sessions you can also toggle tools per invocation (Pi ≥ 1.1.0): `pi -t -analyze_image` removes the re-query tool from the default selection, `pi -t +analyze_image` adds it back to a trimmed one. This complements the persistent `/multimodal-proxy tool off` and `PI_VISION_PROXY_TOOL=off`, which survive across sessions.
261
+
238
262
  ## How it works — Images
239
263
 
240
264
  ```
@@ -361,7 +385,7 @@ When a model is in the grounding registry, a format-specific instruction is appe
361
385
 
362
386
  ## Privacy & security
363
387
 
364
- This extension **sends data to a third-party provider**. By default that is `zai/glm-5.3-flash` for images (`anthropic/claude-sonnet-5` on older Pi versions without GLM 5.3 Flash in the catalog, then `anthropic/claude-sonnet-4-5`) and `xai/grok-4.3` for video/audio. Be aware:
388
+ This extension **sends data to a third-party provider**. By default that is `zai/glm-5.3-flash` for images, falling back through `deepseek/deepseek-v4-flash-vision-exp` → `anthropic/claude-haiku-5-5` (Pi ≥ 1.1.0) → `anthropic/claude-sonnet-5` → `anthropic/claude-sonnet-4-5` to the first candidate in your catalog **and keyed** (`xai/grok-4.3` for video/audio). Be aware:
365
389
 
366
390
  1. **Image and video data is uploaded** to the configured provider on every proxied request. Crop coordinates are applied locally before upload — only the cropped region is sent.
367
391
  2. **Recent conversation context** (last 8 messages, truncated) is uploaded with the image unless you set `/multimodal-proxy context off` or `PI_VISION_PROXY_INCLUDE_CONTEXT=false`. Disable it for sensitive sessions.
@@ -3312,6 +3312,41 @@ describe("1.18.0 key-aware default model resolution", () => {
3312
3312
  assert.equal(DEFAULT_MODEL_FALLBACKS[0]!.modelId, "deepseek-v4-flash-vision-exp");
3313
3313
  });
3314
3314
 
3315
+ it("places Claude Haiku 5.5 between DeepSeek and Sonnet 5 (pi ≥ 1.1.0 catalogs)", () => {
3316
+ const haiku = DEFAULT_MODEL_FALLBACKS.find((m) => m.modelId === "claude-haiku-5-5")!;
3317
+ assert.equal(haiku.provider, "anthropic");
3318
+ const deepseekIdx = DEFAULT_MODEL_FALLBACKS.findIndex((m) => m.provider === "deepseek");
3319
+ const sonnetIdx = DEFAULT_MODEL_FALLBACKS.findIndex((m) => m.modelId === "claude-sonnet-5");
3320
+ assert.ok(deepseekIdx < DEFAULT_MODEL_FALLBACKS.indexOf(haiku));
3321
+ assert.ok(DEFAULT_MODEL_FALLBACKS.indexOf(haiku) < sonnetIdx);
3322
+ });
3323
+
3324
+ it("prefers Haiku 5.5 over Sonnet 5 when both are keyed anthropic candidates", () => {
3325
+ const out = applyDefaultModelFallback(
3326
+ builtin,
3327
+ registryWith(
3328
+ `${builtin.provider}/${builtin.modelId}`,
3329
+ "anthropic/claude-haiku-5-5",
3330
+ "anthropic/claude-sonnet-5",
3331
+ ),
3332
+ false,
3333
+ keyWith("anthropic/claude-haiku-5-5", "anthropic/claude-sonnet-5"),
3334
+ );
3335
+ assert.equal(out.provider, "anthropic");
3336
+ assert.equal(out.modelId, "claude-haiku-5-5");
3337
+ });
3338
+
3339
+ it("skips Haiku 5.5 on pre-1.1.0 catalogs and still lands on Sonnet 5", () => {
3340
+ const out = applyDefaultModelFallback(
3341
+ builtin,
3342
+ registryWith(`${builtin.provider}/${builtin.modelId}`, "anthropic/claude-sonnet-5"),
3343
+ false,
3344
+ keyWith("anthropic/claude-haiku-5-5", "anthropic/claude-sonnet-5"),
3345
+ );
3346
+ assert.equal(out.provider, "anthropic");
3347
+ assert.equal(out.modelId, "claude-sonnet-5");
3348
+ });
3349
+
3315
3350
  it("keeps the default when it is in the catalog and keyed", () => {
3316
3351
  const out = applyDefaultModelFallback(
3317
3352
  builtin,
@@ -0,0 +1,274 @@
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
+ describe("durationMs fallback (pi ≥ 1.1.0 render context)", () => {
201
+ it("uses the host durationMs when details carry no latencyMs", () => {
202
+ assert.equal(
203
+ plain(formatAnalyzeImageResult({ ok: true, provider: "zai", model: "m" }, false, 2500)),
204
+ "✓ zai/m · 2.5s",
205
+ );
206
+ // rows with no details at all (replayed foreign sessions, HTML exports)
207
+ assert.equal(plain(formatAnalyzeImageResult(undefined, false, 987)), "✓ · 1.0s");
208
+ });
209
+
210
+ it("prefers details.latencyMs over the host durationMs", () => {
211
+ const segs = formatAnalyzeImageResult(
212
+ { ok: true, provider: "zai", model: "glm-5.3-flash", latencyMs: 1234 },
213
+ false,
214
+ 99999,
215
+ );
216
+ assert.equal(plain(segs), "✓ zai/glm-5.3-flash · 1.2s");
217
+ });
218
+
219
+ it("ignores non-finite durationMs", () => {
220
+ assert.equal(plain(formatAnalyzeImageResult({ ok: true }, false, Number.NaN)), "✓");
221
+ });
222
+ });
223
+ });
224
+
225
+ describe("formatGenerateImageResult", () => {
226
+ it("summarizes generation with images, cost, and first id", () => {
227
+ const segs = formatGenerateImageResult(
228
+ {
229
+ ok: true,
230
+ provider: "google",
231
+ model: "imagen-4",
232
+ images: [
233
+ { id: HASH32, mimeType: "image/png", width: 1024, height: 768 },
234
+ { id: "f".repeat(32), mimeType: "image/png", width: 1024, height: 768 },
235
+ ],
236
+ costUSD: 0.0311,
237
+ },
238
+ false,
239
+ );
240
+ assert.equal(plain(segs), `✓ 2 images via google/imagen-4 · $0.0311 · image="deadbeef…" +1`);
241
+ });
242
+
243
+ it("renders errors from details or generically", () => {
244
+ assert.equal(
245
+ plain(formatGenerateImageResult({ ok: false, error: "Error: no keyed image model" }, true)),
246
+ "✗ Error: no keyed image model",
247
+ );
248
+ assert.equal(plain(formatGenerateImageResult(undefined, true)), "✗ generation failed");
249
+ });
250
+
251
+ it("falls back to 'done' when details carry no image list", () => {
252
+ assert.equal(plain(formatGenerateImageResult({ ok: true, provider: "google", model: "imagen-4" }, false)), "✓ done via google/imagen-4");
253
+ });
254
+ });
255
+
256
+ describe("analyzeDetailsForTranscript", () => {
257
+ it("strips text and images but keeps display fields", () => {
258
+ const out = analyzeDetailsForTranscript({
259
+ ok: true,
260
+ text: "a".repeat(2000),
261
+ images: [{ id: HASH32 }],
262
+ provider: "zai",
263
+ model: "glm-5.3-flash",
264
+ latencyMs: 120,
265
+ });
266
+ assert.deepEqual(out, { ok: true, provider: "zai", model: "glm-5.3-flash", latencyMs: 120 });
267
+ });
268
+
269
+ it("does not mutate the input", () => {
270
+ const input = { ok: true, text: "keep-me", images: [1, 2] };
271
+ analyzeDetailsForTranscript(input);
272
+ assert.deepEqual(input, { ok: true, text: "keep-me", images: [1, 2] });
273
+ });
274
+ });
@@ -1318,6 +1318,7 @@ export function resolveConfig(
1318
1318
  */
1319
1319
  export const DEFAULT_MODEL_FALLBACKS: ReadonlyArray<{ provider: string; modelId: string }> = [
1320
1320
  { provider: "deepseek", modelId: "deepseek-v4-flash-vision-exp" }, // Pi ≥ 0.84.4, cheap, common key
1321
+ { provider: "anthropic", modelId: "claude-haiku-5-5" }, // Pi ≥ 1.1.0, vision-capable, $0.1/$0.5 per M
1321
1322
  { provider: "anthropic", modelId: "claude-sonnet-5" },
1322
1323
  { provider: "anthropic", modelId: "claude-sonnet-4-5" },
1323
1324
  ];
@@ -3263,6 +3264,213 @@ export function buildToolCacheKey(
3263
3264
  return `${sortedHashes.join("+")}${cropSig ? "#crop:" + cropSig : ""}?q=${questionHash}&m=${modelId}`;
3264
3265
  }
3265
3266
 
3267
+ // ── Tool transcript rendering (pi ≥ 1.0.1 registerToolRenderer) ────────────
3268
+
3269
+ /**
3270
+ * Styling hints for one segment of a tool-transcript line, mapped to
3271
+ * theme.fg tokens by the renderer in vision-proxy.ts. Kept as plain data so
3272
+ * these helpers stay pure and unit-testable without a terminal theme.
3273
+ */
3274
+ export type TranscriptStyle = "plain" | "accent" | "muted" | "dim" | "success" | "warning" | "error";
3275
+
3276
+ export interface TranscriptSegment {
3277
+ text: string;
3278
+ style: TranscriptStyle;
3279
+ }
3280
+
3281
+ /** Collapse whitespace and truncate on a word boundary (surrogate-pair safe). */
3282
+ export function summarizeForTranscript(text: string, max: number): string {
3283
+ const t = text.replace(/\s+/g, " ").trim();
3284
+ if (t.length <= max) return t;
3285
+ let cut = t.slice(0, Math.max(1, max - 1));
3286
+ // Never split a surrogate pair on a hard cut (lone high surrogate → U+FFFD).
3287
+ if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
3288
+ const ws = cut.lastIndexOf(" ");
3289
+ if (ws > cut.length * 0.6) cut = cut.slice(0, ws);
3290
+ return `${cut.trimEnd()}…`;
3291
+ }
3292
+
3293
+ /** Defensive finite-number check for values coming from model-authored args. */
3294
+ function isFiniteNumber(v: unknown): v is number {
3295
+ return typeof v === "number" && Number.isFinite(v);
3296
+ }
3297
+
3298
+ /** Compact percentage for crop summaries: integral values stay integral. */
3299
+ function pct(v: number): string {
3300
+ const p = v * 100;
3301
+ return Math.abs(p - Math.round(p)) < 0.05 ? String(Math.round(p)) : String(Math.round(p * 10) / 10);
3302
+ }
3303
+
3304
+ /** Safe one-line JSON rendering of an arbitrary value (circular-safe). */
3305
+ function safeJson(value: unknown, max = 120): string {
3306
+ try {
3307
+ return summarizeForTranscript(JSON.stringify(value) ?? String(value), max);
3308
+ } catch {
3309
+ return String(value);
3310
+ }
3311
+ }
3312
+
3313
+ /**
3314
+ * Shorten one analyze_image image reference for display: a recall id
3315
+ * (`image="<hash>"`, `sha256:<hash>#…`, or a bare hash) becomes
3316
+ * `image="deadbeef…"`; a file path becomes its file name (Windows or POSIX).
3317
+ */
3318
+ export function summarizeImageRef(ref: string): string {
3319
+ const s = ref.trim().replace(/^["']|["']$/g, "");
3320
+ const quoted = /image="?([a-f0-9]{16,64})"?/i.exec(s);
3321
+ if (quoted) return `image="${quoted[1]!.slice(0, 8)}…"`;
3322
+ const bare = parseRecallRef(s);
3323
+ if (bare) return `image="${bare.slice(0, 8)}…"`;
3324
+ const base = s.replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? s;
3325
+ return summarizeForTranscript(base, 48);
3326
+ }
3327
+
3328
+ /**
3329
+ * Compact crop descriptor for the transcript: `bottom-right@0`,
3330
+ * `25%,13% 50%×50%@1`, or `128,64 256×256@0`. Empty string when nothing
3331
+ * recognizable.
3332
+ */
3333
+ export function summarizeCrop(crop: unknown): string {
3334
+ if (typeof crop !== "object" || crop === null) return "";
3335
+ const c = crop as Record<string, unknown>;
3336
+ const idx =
3337
+ isFiniteNumber(c.image_index) ? `@${Math.trunc(c.image_index)}` : "";
3338
+ if (typeof c.region === "string" && c.region.trim()) return `${c.region.trim()}${idx}`;
3339
+ const box = (c.normalized ?? c.pixels) as Record<string, unknown> | undefined;
3340
+ if (
3341
+ typeof box === "object" &&
3342
+ box !== null &&
3343
+ isFiniteNumber(box.x) &&
3344
+ isFiniteNumber(box.y) &&
3345
+ isFiniteNumber(box.width) &&
3346
+ isFiniteNumber(box.height)
3347
+ ) {
3348
+ if (c.normalized !== undefined) {
3349
+ return `${pct(box.x)}%,${pct(box.y)}% ${pct(box.width)}%×${pct(box.height)}%${idx}`;
3350
+ }
3351
+ return `${Math.round(box.x)},${Math.round(box.y)} ${Math.round(box.width)}×${Math.round(box.height)}${idx}`;
3352
+ }
3353
+ return idx ? `crop${idx}` : "";
3354
+ }
3355
+
3356
+ /** One-line transcript summary of an analyze_image call (pi ≥ 1.0.1 renderer). */
3357
+ export function formatAnalyzeImageCall(args: unknown): TranscriptSegment[] {
3358
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
3359
+ return [{ text: safeJson(args), style: "muted" }];
3360
+ }
3361
+ const a = args as Record<string, unknown>;
3362
+ const out: TranscriptSegment[] = [];
3363
+ const question = typeof a.question === "string" ? a.question : "";
3364
+ out.push({ text: `"${summarizeForTranscript(question, 120)}"`, style: "accent" });
3365
+ const images = Array.isArray(a.images)
3366
+ ? a.images.filter((r): r is string => typeof r === "string" && r.trim() !== "")
3367
+ : [];
3368
+ if (images.length > 0) {
3369
+ const shown = images.slice(0, 2).map(summarizeImageRef);
3370
+ const more = images.length - shown.length;
3371
+ out.push({
3372
+ text:
3373
+ ` · ${images.length} image${images.length === 1 ? "" : "s"}` +
3374
+ (shown.length ? ` (${shown.join(", ")}${more > 0 ? ` +${more}` : ""})` : ""),
3375
+ style: "muted",
3376
+ });
3377
+ }
3378
+ const crops = Array.isArray(a.crop) ? a.crop.map(summarizeCrop).filter((s) => s !== "") : [];
3379
+ if (crops.length === 1) out.push({ text: ` · crop ${crops[0]}`, style: "dim" });
3380
+ else if (crops.length > 1) out.push({ text: ` · crops ×${crops.length}`, style: "dim" });
3381
+ const model = typeof a.model === "string" && a.model.trim() ? a.model.trim() : undefined;
3382
+ if (model) out.push({ text: ` · ${summarizeForTranscript(model, 60)}`, style: "muted" });
3383
+ return out;
3384
+ }
3385
+
3386
+ /** One-line transcript summary of a generate_image call (pi ≥ 1.0.1 renderer). */
3387
+ export function formatGenerateImageCall(args: unknown): TranscriptSegment[] {
3388
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
3389
+ return [{ text: safeJson(args), style: "muted" }];
3390
+ }
3391
+ const a = args as Record<string, unknown>;
3392
+ const prompt = typeof a.prompt === "string" ? a.prompt : "";
3393
+ const out: TranscriptSegment[] = [{ text: `"${summarizeForTranscript(prompt, 120)}"`, style: "accent" }];
3394
+ const model = typeof a.model === "string" && a.model.trim() ? a.model.trim() : undefined;
3395
+ if (model) out.push({ text: ` · ${summarizeForTranscript(model, 60)}`, style: "muted" });
3396
+ return out;
3397
+ }
3398
+
3399
+ function recordOf(details: unknown): Record<string, unknown> | undefined {
3400
+ return typeof details === "object" && details !== null && !Array.isArray(details)
3401
+ ? (details as Record<string, unknown>)
3402
+ : undefined;
3403
+ }
3404
+
3405
+ /**
3406
+ * One-line transcript summary of an analyze_image result from its `details`.
3407
+ * `durationMs` (pi ≥ 1.1.0 host render context) is a display fallback for rows
3408
+ * whose `details` carry no `latencyMs` — replayed sessions, foreign/HTML
3409
+ * exports, results recorded before `details` existed. Details always win:
3410
+ * `latencyMs` measures the vision call itself, while the host's `durationMs`
3411
+ * covers the whole `execute()` (consent checks, crop encoding included).
3412
+ */
3413
+ export function formatAnalyzeImageResult(
3414
+ details: unknown,
3415
+ isError: boolean,
3416
+ durationMs?: number,
3417
+ ): TranscriptSegment[] {
3418
+ const d = recordOf(details);
3419
+ if (isError || d?.ok === false) {
3420
+ const msg = typeof d?.error === "string" && d.error ? d.error : "analysis failed";
3421
+ return [{ text: `✗ ${summarizeForTranscript(msg, 160)}`, style: "error" }];
3422
+ }
3423
+ const segments: TranscriptSegment[] = [{ text: "✓", style: "success" }];
3424
+ const provider = typeof d?.provider === "string" && d.provider ? d.provider : undefined;
3425
+ const modelId = typeof d?.model === "string" && d.model ? d.model : undefined;
3426
+ if (provider && modelId) segments.push({ text: ` ${summarizeForTranscript(`${provider}/${modelId}`, 80)}`, style: "muted" });
3427
+ else if (modelId) segments.push({ text: ` ${summarizeForTranscript(modelId, 80)}`, style: "muted" });
3428
+ if (d?.cached === true) segments.push({ text: " · cached", style: "dim" });
3429
+ const latency = isFiniteNumber(d?.latencyMs) ? d.latencyMs : isFiniteNumber(durationMs) ? durationMs : undefined;
3430
+ if (latency !== undefined) {
3431
+ segments.push({ text: ` · ${(Math.max(0, latency) / 1000).toFixed(1)}s`, style: "dim" });
3432
+ }
3433
+ return segments;
3434
+ }
3435
+
3436
+ /** One-line transcript summary of a generate_image result from its `details`. */
3437
+ export function formatGenerateImageResult(details: unknown, isError: boolean): TranscriptSegment[] {
3438
+ const d = recordOf(details);
3439
+ if (isError || d?.ok === false) {
3440
+ const msg = typeof d?.error === "string" && d.error ? d.error : "generation failed";
3441
+ return [{ text: `✗ ${summarizeForTranscript(msg, 160)}`, style: "error" }];
3442
+ }
3443
+ const images = Array.isArray(d?.images)
3444
+ ? (d!.images as unknown[]).filter((x): x is Record<string, unknown> => typeof x === "object" && x !== null)
3445
+ : [];
3446
+ const provider = typeof d?.provider === "string" && d.provider ? d.provider : undefined;
3447
+ const modelId = typeof d?.model === "string" && d.model ? d.model : undefined;
3448
+ const count = images.length > 0 ? `${images.length} image${images.length === 1 ? "" : "s"}` : "done";
3449
+ const segments: TranscriptSegment[] =
3450
+ provider && modelId
3451
+ ? [{ text: `✓ ${count} via ${summarizeForTranscript(`${provider}/${modelId}`, 80)}`, style: "success" }]
3452
+ : [{ text: `✓ ${count}`, style: "success" }];
3453
+ if (isFiniteNumber(d?.costUSD)) segments.push({ text: ` · $${Math.max(0, d.costUSD).toFixed(4)}`, style: "dim" });
3454
+ const firstId = typeof images[0]?.id === "string" && images[0].id ? images[0].id : undefined;
3455
+ if (firstId) {
3456
+ const more = images.length > 1 ? ` +${images.length - 1}` : "";
3457
+ segments.push({ text: ` · image="${firstId.slice(0, 8)}…"${more}`, style: "dim" });
3458
+ }
3459
+ return segments;
3460
+ }
3461
+
3462
+ /**
3463
+ * Copy of a structured analyze_image result without the fields the
3464
+ * transcript renderer never reads — `details` must stay small because it is
3465
+ * persisted in the session log alongside the (already stored) text fence.
3466
+ */
3467
+ export function analyzeDetailsForTranscript(structured: Record<string, unknown>): Record<string, unknown> {
3468
+ const rest: Record<string, unknown> = { ...structured };
3469
+ delete rest.text;
3470
+ delete rest.images;
3471
+ return rest;
3472
+ }
3473
+
3266
3474
  // ── Fence builders ────────────────────────────────────────────────────────
3267
3475
 
3268
3476
  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,229 @@ 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
+ /** pi ≥ 1.1.0: recorded execute() duration from the final result. */
2411
+ durationMs?: number;
2412
+ }
2413
+
2414
+ /** AgentToolResult subset the result renderer receives. */
2415
+ interface RenderResultLike {
2416
+ content?: ReadonlyArray<{ type?: string; text?: string }>;
2417
+ details?: unknown;
2418
+ }
2419
+
2420
+ type ToolRenderersLike = {
2421
+ renderCall?: (args: unknown, theme: RenderThemeLike, context: RenderContextLike) => ComponentLike;
2422
+ renderResult?: (
2423
+ result: RenderResultLike,
2424
+ options: { expanded: boolean; isPartial: boolean },
2425
+ theme: RenderThemeLike,
2426
+ context: RenderContextLike,
2427
+ ) => ComponentLike;
2428
+ };
2429
+
2430
+ /**
2431
+ * pi-tui's Text (ANSI-aware word wrap), loaded lazily so an unresolvable
2432
+ * package can never block extension load — the crash-loop lesson from
2433
+ * 1.16.0. Warmed at registration time, long before the first tool row
2434
+ * renders.
2435
+ */
2436
+ let _textCtor: (new (text: string, paddingX?: number, paddingY?: number) => ComponentLike) | undefined;
2437
+ let _textLoadTried = false;
2438
+
2439
+ function preloadTextComponent(): void {
2440
+ if (_textLoadTried) return;
2441
+ _textLoadTried = true;
2442
+ void import("@earendil-works/pi-tui")
2443
+ .then((m) => {
2444
+ const ctor = (
2445
+ m as { Text?: new (text: string, paddingX?: number, paddingY?: number) => ComponentLike }
2446
+ ).Text;
2447
+ if (ctor) _textCtor = ctor;
2448
+ })
2449
+ .catch(() => {
2450
+ // pi-tui unreachable — the plain-lines fallback below keeps rendering
2451
+ });
2452
+ }
2453
+
2454
+ /**
2455
+ * Fixed-lines component used only when pi-tui's Text cannot load.
2456
+ * Truncates each line to the render width (ANSI-aware, rough wcwidth) —
2457
+ * pi's differential renderer stops on "rendered line exceeds terminal
2458
+ * width", so an overwide fallback line is worse than an ugly one.
2459
+ */
2460
+ function plainLines(text: string): ComponentLike {
2461
+ return {
2462
+ render: (width: number) => text.split("\n").map((line) => truncateAnsiLine(line, Math.max(1, width))),
2463
+ invalidate: () => {},
2464
+ };
2465
+ }
2466
+
2467
+ /** Rough wcwidth: 2 for the common wide ranges, 0 for combining marks, else 1. */
2468
+ function codePointWidth(cp: number): number {
2469
+ if (cp >= 0x0300 && cp <= 0x036f) return 0; // combining diacritics
2470
+ if (
2471
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
2472
+ (cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals … Yi
2473
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
2474
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compat ideographs
2475
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
2476
+ (cp >= 0xff00 && cp <= 0xff60) ||
2477
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
2478
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji blocks
2479
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK ext
2480
+ ) {
2481
+ return 2;
2482
+ }
2483
+ return 1;
2484
+ }
2485
+
2486
+ /** ANSI-preserving truncation of one styled line to a visible width. */
2487
+ function truncateAnsiLine(line: string, width: number): string {
2488
+ let out = "";
2489
+ let w = 0;
2490
+ let i = 0;
2491
+ while (i < line.length) {
2492
+ if (line[i] === "\x1b") {
2493
+ const m = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
2494
+ const seq = m ? m[0] : line[i]!;
2495
+ out += seq;
2496
+ i += seq.length;
2497
+ continue;
2498
+ }
2499
+ const cp = line.codePointAt(i)!;
2500
+ const s = String.fromCodePoint(cp);
2501
+ const cw = codePointWidth(cp);
2502
+ if (w + cw > width) break;
2503
+ out += s;
2504
+ w += cw;
2505
+ i += s.length;
2506
+ }
2507
+ return out;
2508
+ }
2509
+
2510
+ function textComponent(text: string): ComponentLike {
2511
+ return _textCtor ? new _textCtor(text, 0, 0) : plainLines(text);
2512
+ }
2513
+
2514
+ function styleSegments(segments: readonly TranscriptSegment[], theme: RenderThemeLike): string {
2515
+ let out = "";
2516
+ for (const seg of segments) {
2517
+ out += seg.style === "plain" ? seg.text : theme.fg(seg.style, seg.text);
2518
+ }
2519
+ return out;
2520
+ }
2521
+
2522
+ function textBlocks(content: ReadonlyArray<{ type?: string; text?: string }> | undefined): string[] {
2523
+ if (!content) return [];
2524
+ const out: string[] = [];
2525
+ for (const block of content) {
2526
+ if (block?.type === "text" && typeof block.text === "string" && block.text) out.push(block.text);
2527
+ }
2528
+ return out;
2529
+ }
2530
+
2531
+ function toolTitle(name: string, theme: RenderThemeLike): string {
2532
+ return theme.fg("toolTitle", theme.bold(`${name} `));
2533
+ }
2534
+
2535
+ /** Compact result component: summary line, plus the full text content when expanded. */
2536
+ function resultComponent(
2537
+ summary: readonly TranscriptSegment[],
2538
+ result: RenderResultLike,
2539
+ options: { expanded: boolean },
2540
+ theme: RenderThemeLike,
2541
+ ): ComponentLike {
2542
+ let text = styleSegments(summary, theme);
2543
+ if (options.expanded) {
2544
+ // All text blocks: a generate_image result on a text-only base model
2545
+ // carries the header plus per-image description fences that replaced
2546
+ // the image blocks — the first block alone would hide them.
2547
+ const body = textBlocks(result.content).join("\n\n");
2548
+ if (body) {
2549
+ // Re-apply the style per line: pi resets styling after every line.
2550
+ text += `\n${body.split("\n").map((line) => theme.fg("toolOutput", line)).join("\n")}`;
2551
+ }
2552
+ }
2553
+ return textComponent(text);
2554
+ }
2555
+
2556
+ /**
2557
+ * Register transcript renderers for analyze_image/generate_image (pi ≥
2558
+ * 1.0.1). Registered through pi.registerToolRenderer — instead of inline
2559
+ * renderCall/renderResult on the tool definitions — so the compact rows
2560
+ * also cover tools that are *not registered* at render time: calls
2561
+ * replayed from a session where the tool is now disabled (`tool off` /
2562
+ * mode off), resumed sessions rendered before extension load, and HTML
2563
+ * exports. `next() ?? {}` keeps renderers from later resolvers / the
2564
+ * registered tool for the keys we don't set. Generated images keep
2565
+ * rendering inline regardless: pi draws image blocks from the result
2566
+ * content next to the renderer output, independent of renderResult.
2567
+ */
2568
+ function registerToolRenderers() {
2569
+ const api = pi as unknown as {
2570
+ registerToolRenderer?: (
2571
+ resolver: (toolName: string, next: () => ToolRenderersLike | undefined) => ToolRenderersLike | undefined,
2572
+ ) => void;
2573
+ };
2574
+ if (typeof api.registerToolRenderer !== "function") return; // pi < 1.0.1
2575
+ preloadTextComponent();
2576
+ api.registerToolRenderer((toolName, next) => {
2577
+ if (toolName === "analyze_image") {
2578
+ return {
2579
+ ...(next() ?? {}),
2580
+ renderCall: (args, theme) =>
2581
+ textComponent(toolTitle("analyze_image", theme) + styleSegments(formatAnalyzeImageCall(args), theme)),
2582
+ renderResult: (result, options, theme, context) => {
2583
+ if (options.isPartial) return textComponent(theme.fg("warning", "Analyzing…"));
2584
+ return resultComponent(
2585
+ formatAnalyzeImageResult(result?.details, context?.isError === true, context?.durationMs),
2586
+ result ?? {},
2587
+ options,
2588
+ theme,
2589
+ );
2590
+ },
2591
+ };
2592
+ }
2593
+ if (toolName === "generate_image") {
2594
+ return {
2595
+ ...(next() ?? {}),
2596
+ renderCall: (args, theme) =>
2597
+ textComponent(toolTitle("generate_image", theme) + styleSegments(formatGenerateImageCall(args), theme)),
2598
+ renderResult: (result, options, theme, context) => {
2599
+ if (options.isPartial) return textComponent(theme.fg("warning", "Generating…"));
2600
+ return resultComponent(
2601
+ formatGenerateImageResult(result?.details, context?.isError === true),
2602
+ result ?? {},
2603
+ options,
2604
+ theme,
2605
+ );
2606
+ },
2607
+ };
2608
+ }
2609
+ return next();
2610
+ });
2611
+ }
2612
+
2384
2613
  // ── Virtual model (pi ≥ 0.99) ─────────────────────────────────────────────
2385
2614
 
2386
2615
  const VIRTUAL_PROVIDER = "multimodal-proxy";
@@ -2571,6 +2800,10 @@ export default function (pi: ExtensionAPI) {
2571
2800
  // unreadable config file — session_start surfaces the error path
2572
2801
  });
2573
2802
 
2803
+ // Transcript renderers for analyze_image/generate_image rows (pi ≥ 1.0.1).
2804
+ // Registered once at load; the resolver serves any later session state.
2805
+ registerToolRenderers();
2806
+
2574
2807
  /** Register or unregister the analyze_image tool based on config. */
2575
2808
  function syncToolRegistration(config: VisionConfig, runtimeCtx?: ExtensionContext) {
2576
2809
  const shouldHaveTool = config.mode !== "off" && config.tool === "on";
@@ -2630,6 +2863,9 @@ export default function (pi: ExtensionAPI) {
2630
2863
  // codemode scripts. Older hosts ignore both fields.
2631
2864
  ...(outcome.isError ? { isError: true } : {}),
2632
2865
  ...(outcome.structured ? { structuredContent: outcome.structured } : {}),
2866
+ // `details` feeds the transcript renderer (pi ≥ 1.0.1) without
2867
+ // the model seeing it; heavy fields (text, images) are stripped.
2868
+ ...(outcome.structured ? { details: analyzeDetailsForTranscript(outcome.structured) } : {}),
2633
2869
  };
2634
2870
  },
2635
2871
  });
@@ -2705,6 +2941,9 @@ export default function (pi: ExtensionAPI) {
2705
2941
  content: outcome.content,
2706
2942
  ...(outcome.isError ? { isError: true } : {}),
2707
2943
  ...(outcome.structured ? { structuredContent: outcome.structured } : {}),
2944
+ // `details` feeds the transcript renderer (pi ≥ 1.0.1); the
2945
+ // structured payload is already display-sized (ids, dims, cost).
2946
+ ...(outcome.structured ? { details: outcome.structured } : {}),
2708
2947
  ...(outcome.usage !== undefined ? { usage: outcome.usage } : {}),
2709
2948
  };
2710
2949
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.20.0",
3
+ "version": "1.22.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"