pi-vision-handoff 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -26,8 +26,8 @@ The `pi-umans-provider` extension quietly solved this for GLM 5.1: a hardcoded "
26
26
 
27
27
  - **Pick any vision-capable model** from your registry via an interactive picker — OpenAI, Anthropic, Google, Ollama, or any custom provider pi knows about.
28
28
  - Your choice **persists** to `~/.pi/agent/extensions/pi-vision-handoff.json`.
29
- - For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model **before** the request is sent, then **swaps the image block for the description** in the actual provider payload.
30
- - Works across all three request formats pi uses — OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages — detected by block shape.
29
+ - For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model and **inserts the description before the kept image** in the stored `read`-tool result **before** pi-ai can strip the image for non-vision models. The image stays for kitty rendering; pi-ai later strips only the image block, leaving the description text for the model.
30
+ - Works across all four image-block shapes pi uses — the three provider-transformed formats (OpenAI Chat Completions, OpenAI Responses, Anthropic Messages) plus pi-ai's internal `{ type: "image", data, mimeType }` emitted by the `read` tool — detected by shape.
31
31
  - Descriptions are **cached per image hash** (LRU), so the swap is instant by the time the request fires.
32
32
 
33
33
  No `settings.json` touched. No per-provider glue. Pick a describer once and every text-only model you own can suddenly see.
@@ -35,12 +35,13 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
35
35
  ## Features
36
36
 
37
37
  - **🎮 Interactive picker** — `/vision-handoff` opens a TUI listing every model, vision-capable ones first (👁), to choose your describer.
38
+ - **🖼️ Read-tool hijack** — `read`-tool images are intercepted at `tool_result` time, before pi-ai can strip them for non-vision models. The kept image still renders inline (kitty); the description is inserted as a text block the text-only model consumes.
38
39
  - **🔌 Provider-agnostic** — uses pi's own model execution machinery (`@earendil-works/pi-ai`'s `complete()`), so it works with any provider/configured model, including custom provider extensions.
39
40
  - **🧠 Automatic targets** — by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
40
41
  - **🗂️ Explicit overrides** — force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
41
42
  - **⚡ Cache-warmed** — `before_agent_start` describes attached images the moment you submit, so the request is rarely delayed.
42
43
  - **🛡️ Graceful degradation** — no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
43
- - **🔧 Tunable** — cap description length and cache size in the config file.
44
+ - **🔧 Tunable** — cap description length (`maxDescriptionLines`; unbounded by default, so `read`'s native `ctrl+o` collapse handles compactness) and cache size, in the config file.
44
45
 
45
46
  ## Usage
46
47
 
@@ -71,6 +72,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
71
72
  "handoffModels": ["ollama/llava:13b"],
72
73
  "maxTokens": 1024,
73
74
  "cacheMax": 50,
75
+ "maxDescriptionLines": 0,
74
76
  "prompt": "Describe this image exhaustively…",
75
77
  "userPromptPrefix": "The user's request about this image: "
76
78
  }
@@ -84,6 +86,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
84
86
  | `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
85
87
  | `maxTokens` | `1024` | Cap on a single description's output. |
86
88
  | `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
89
+ | `maxDescriptionLines` | `0` | Cap on description lines (`0` = unbounded). Default keeps the full description so the `read` tool's native collapse (`ctrl+o`) handles compactness and the model gets complete context; setting `> 0` applies a lossy head-cap to both the TUI render and the model. |
87
90
  | `prompt` | _(built-in)_ | Override the describer system prompt. |
88
91
  | `userPromptPrefix` | _(built-in)_ | Override the prefix prepended to your original prompt. |
89
92
 
@@ -129,31 +132,47 @@ pi -e ./vision-handoff.ts
129
132
 
130
133
  ## How It Works
131
134
 
132
- ```
133
- You submit a prompt + image, on a text-only model
134
- before_agent_start
135
- is this model a handoff target? (non-vision, or in handoffModels)
136
- for each attached image describeImage() with your chosen vision model
137
- - complete() via pi-ai (resolves API key/headers/baseUrl for you)
138
- - cached by sha256(mime + base64)
139
- fire-and-forget warms the cache
140
- before_provider_request
141
- walk the provider payload's messages[]
142
- for every image block (detected by shape) → swap for a text block:
143
- "[Image: <cached description>]"
144
- returns the modified payload to pi
145
-
146
- Result: the text-only model receives a vivid text description in place of
147
- the image, and your turn proceeds normally.
148
- ```
135
+ Read-tool images (the common case — paste a screenshot, `read` an image file):
136
+
137
+ read tool returns an image block
138
+ tool_result (toolName === "read")
139
+ fires BEFORE pi-ai's transformMessages can strip the image for
140
+ non-vision models (the strip would leave only a "(tool image
141
+ omitted)" placeholder too late to describe)
142
+ is this model a handoff target? (non-vision, or in handoffModels)
143
+ • for each {type:"image",...} block describeImage() with your
144
+ chosen vision model
145
+ - complete() via pi-ai (resolves API key/headers/baseUrl)
146
+ - cached by sha256(mime + base64)
147
+ AUGMENTS the stored result: inserts "[Image: <description>]" as a
148
+ text block BEFORE the kept image, returns {content} to pi
149
+ the image stays in content kitty renders it inline
150
+ at provider time, pi-ai strips ONLY the image block for non-vision
151
+ models — the inserted description text passes through to the model
152
+
153
+ User-attached images (pasted into the prompt, not read via a tool):
154
+
155
+ → before_agent_start
156
+ • warms the description cache for attached images (fire-and-forget)
157
+ → before_provider_request
158
+ • walks the provider payload, swaps image blocks for "[Image: <desc>]"
159
+ • NOTE: for non-vision models, pi-ai strips image blocks BEFORE this
160
+ hook fires — so user-attached images on text-only models are not yet
161
+ described. Use the `read` tool on the image file instead, which routes
162
+ through the tool_result path above.
163
+
164
+ Result: the text-only model receives a vivid text description alongside the
165
+ (now-stripped) image reference, and your turn proceeds normally — while the
166
+ terminal still renders the image inline via kitty.
149
167
 
150
168
  ### Image-block formats handled
151
169
 
152
- | API | Image block shape | Replacement |
153
- |-----|-------------------|-------------|
154
- | OpenAI Chat Completions | `{ type: "image_url", image_url: { url: "data:…" } }` | `{ type: "text", text }` |
155
- | OpenAI Responses | `{ type: "input_image", image_url: "data:…" }` | `{ type: "input_text", text }` |
156
- | Anthropic Messages | `{ type: "image", source: { type: "base64", media_type, data } }` | `{ type: "text", text }` |
170
+ | Hook | Image block shape | Replacement |
171
+ |------|-------------------|-------------|
172
+ | `tool_result` (read) | `{ type: "image", data, mimeType }` (pi-ai internal) | description text block inserted **before** the kept image |
173
+ | `before_provider_request` | `{ type: "image_url", image_url: { url: "data:…" } }` (OpenAI Chat Completions) | `{ type: "text", text }` |
174
+ | `before_provider_request` | `{ type: "input_image", image_url: "data:…" }` (OpenAI Responses) | `{ type: "input_text", text }` |
175
+ | `before_provider_request` | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | `{ type: "text", text }` |
157
176
 
158
177
  The describer call itself goes through pi's normal model machinery (`complete()`), **not** the agent event loop — so it never re-triggers `before_provider_request` (no recursion).
159
178
 
@@ -171,7 +190,7 @@ The describer call itself goes through pi's normal model machinery (`complete()`
171
190
 
172
191
  ```bash
173
192
  pnpm install
174
- pnpm test # Vitest unit tests (31 passing)
193
+ pnpm test # Vitest unit tests (50 passing)
175
194
  pnpm typecheck # TypeScript validation
176
195
  pnpm lint:dead # Dead code detection (knip)
177
196
  ```
@@ -186,7 +205,7 @@ pnpm lint:dead # Dead code detection (knip)
186
205
  │ └── vision-model-selector.ts # Interactive picker TUI component
187
206
  ├── __tests__/unit/
188
207
  │ ├── config-dir.test.ts # Ensures getAgentDir() usage
189
- │ └── vision-handoff.test.ts # Config, refs, image-block extraction, round-trip
208
+ │ └── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
190
209
  ├── package.json
191
210
  ├── tsconfig.json
192
211
  ├── knip.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Give text-only pi models vision — describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * convention pi-model-sort uses for picker-backed extensions.
6
6
  */
7
7
 
8
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
8
9
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
11
  import { join } from "node:path";
@@ -44,6 +45,41 @@ export const DEFAULT_CACHE_MAX = 50;
44
45
  /** Per-description request timeout. */
45
46
  export const DESCRIBE_TIMEOUT_MS = 30_000;
46
47
 
48
+ /**
49
+ * Default cap on the number of lines kept from a description before truncation.
50
+ *
51
+ * Mirrors pi core's read-result truncation: tool output is bounded for both the
52
+ * TUI render and the model context. The stored `result.content` is the single
53
+ * source for both surfaces (no decouple point exists at `tool_result` — it's the
54
+ * only hook that still has the raw image before pi-ai strips it), so the cap
55
+ * applies uniformly. 0 = unbounded (default): the read tool's native collapse
56
+ * handles compactness and `ctrl+o` expands to the full description.
57
+ */
58
+ export const DEFAULT_MAX_DESCRIPTION_LINES = 0;
59
+
60
+ /**
61
+ * The note pi core's `read` tool appends to image-result text blocks when the
62
+ * active model lacks image input (see `getNonVisionImageNote` in pi core).
63
+ *
64
+ * Once this extension inserts a description, the note becomes self-
65
+ * contradicting ("image will be omitted" vs. the vivid description that
66
+ * follows) and confuses the model. {@link stripNonVisionImageNote} removes it.
67
+ */
68
+ export const NON_VISION_IMAGE_NOTE =
69
+ "[Current model does not support images. The image will be omitted from this request.]";
70
+
71
+ /**
72
+ * Remove {@link NON_VISION_IMAGE_NOTE} from a text block.
73
+ *
74
+ * The read tool appends the note as `\n${NOTE}` (always the trailing segment of
75
+ * the metadata text block), so this also collapses the orphaned newline it
76
+ * leaves behind. Safe to call on text that does not contain the note (no-op).
77
+ */
78
+ export function stripNonVisionImageNote(text: string): string {
79
+ if (!text.includes(NON_VISION_IMAGE_NOTE)) return text;
80
+ return text.split(NON_VISION_IMAGE_NOTE).join("").replace(/\n+$/, "");
81
+ }
82
+
47
83
  export interface VisionHandoffConfig {
48
84
  /** Master switch. When false, no handoff occurs even if a vision model is configured. */
49
85
  enabled: boolean;
@@ -57,6 +93,12 @@ export interface VisionHandoffConfig {
57
93
  maxTokens: number;
58
94
  /** Max images kept in the in-memory description cache. */
59
95
  cacheMax: number;
96
+ /**
97
+ * Max lines kept from a description before truncation (0 = unbounded).
98
+ * Bounds both the TUI render and the model context, like pi core's tool-output
99
+ * truncation.
100
+ */
101
+ maxDescriptionLines: number;
60
102
  /** Override the describer system prompt (defaults to DEFAULT_VISION_PROMPT). */
61
103
  prompt?: string;
62
104
  /** Override the user-prompt prefix (defaults to DEFAULT_USER_PROMPT_PREFIX). */
@@ -70,6 +112,7 @@ export const DEFAULT_CONFIG: VisionHandoffConfig = {
70
112
  handoffModels: [],
71
113
  maxTokens: DEFAULT_MAX_TOKENS,
72
114
  cacheMax: DEFAULT_CACHE_MAX,
115
+ maxDescriptionLines: DEFAULT_MAX_DESCRIPTION_LINES,
73
116
  };
74
117
 
75
118
  /** Parse a "provider/id" reference. Returns null if malformed. */
@@ -120,6 +163,14 @@ export function normalizeConfig(raw: unknown): VisionHandoffConfig {
120
163
  if (typeof obj.cacheMax === "number" && Number.isFinite(obj.cacheMax) && obj.cacheMax > 0) {
121
164
  base.cacheMax = Math.floor(obj.cacheMax);
122
165
  }
166
+ // maxDescriptionLines: any non-negative finite integer. 0 = unbounded.
167
+ if (
168
+ typeof obj.maxDescriptionLines === "number" &&
169
+ Number.isFinite(obj.maxDescriptionLines) &&
170
+ obj.maxDescriptionLines >= 0
171
+ ) {
172
+ base.maxDescriptionLines = Math.floor(obj.maxDescriptionLines);
173
+ }
123
174
  if (typeof obj.prompt === "string" && obj.prompt.trim()) base.prompt = obj.prompt;
124
175
  if (typeof obj.userPromptPrefix === "string") base.userPromptPrefix = obj.userPromptPrefix;
125
176
 
@@ -181,10 +232,20 @@ export function extractImageFromBlock(block: unknown): ExtractedImage | null {
181
232
  return null;
182
233
  }
183
234
 
235
+ // anthropic-messages image block (wrapped in `source`).
184
236
  if (b.type === "image" && b.source?.type === "base64" && typeof b.source.data === "string") {
185
237
  return { data: b.source.data, mimeType: b.source.media_type || "image/png" };
186
238
  }
187
239
 
240
+ // pi-ai internal image block — emitted by the `read` tool and carried by
241
+ // ToolResultEvent.content / user-message content blocks (regardless of whether
242
+ // the active model declares image input):
243
+ // { type: "image", data: "<base64>", mimeType }
244
+ // Distinct from the anthropic shape above: no `source` wrapper, direct fields.
245
+ if (b.type === "image" && typeof b.data === "string") {
246
+ return { data: b.data, mimeType: b.mimeType || "image/png" };
247
+ }
248
+
188
249
  return null;
189
250
  }
190
251
 
@@ -196,3 +257,105 @@ export function makeReplacementText(block: unknown, description: string): Record
196
257
  }
197
258
  return { type: "text", text: description };
198
259
  }
260
+
261
+ /** Outcome of {@link truncateDescription}. */
262
+ export interface TruncatedDescription {
263
+ text: string;
264
+ /** True iff lines were dropped. */
265
+ truncated: boolean;
266
+ /** Number of trailing lines removed. */
267
+ hidden: number;
268
+ }
269
+
270
+ /**
271
+ * Head-truncate a description to its first `maxLines` lines with a
272
+ * "... (N more lines)" footer, mirroring pi core's read-result truncation
273
+ * notice (which itself surfaces "... (N more lines, ctrl+o to expand)").
274
+ *
275
+ * Returns the text unchanged when it is at or below the limit, or when
276
+ * `maxLines` is non-positive (0 = unbounded). Head-truncation keeps the leading
277
+ * layout / text-content / structure sections, which are typically the most
278
+ * actionable for a coding agent.
279
+ */
280
+ export function truncateDescription(text: string, maxLines: number): TruncatedDescription {
281
+ if (!maxLines || maxLines <= 0) {
282
+ return { text, truncated: false, hidden: 0 };
283
+ }
284
+ const lines = text.split("\n");
285
+ if (lines.length <= maxLines) {
286
+ return { text, truncated: false, hidden: 0 };
287
+ }
288
+ const hidden = lines.length - maxLines;
289
+ const kept = lines.slice(0, maxLines).join("\n");
290
+ return { text: `${kept}\n... (${hidden} more lines)`, truncated: true, hidden };
291
+ }
292
+
293
+ /** Outcome of {@link insertImageDescriptions}. */
294
+ export interface ReplacedContent {
295
+ content: (TextContent | ImageContent)[];
296
+ /** True iff at least one image block had a description inserted before it. */
297
+ changed: boolean;
298
+ }
299
+
300
+ /**
301
+ * Insert a description text block before each image block in a tool-result /
302
+ * message content array, KEEPING the image block in place.
303
+ *
304
+ * Why insert (not replace): the stored tool-result content is the single
305
+ * source for both the TUI render (kitty inline images read it via
306
+ * `result.content.filter(c => c.type === "image")`) and the provider payload.
307
+ * Replacing the image would strip it from the terminal render. Keeping the
308
+ * image preserves kitty rendering; the inserted description still reaches
309
+ * non-vision models because pi-ai's `downgradeUnsupportedImages` only rewrites
310
+ * `type: "image"` blocks for non-vision models — text blocks (including this
311
+ * description) pass through untouched to the provider.
312
+ *
313
+ * `describe` is injected (rather than calling the vision model directly) so the
314
+ * extract → describe → insert pipeline is unit-testable without standing up a
315
+ * provider, registry, and API call. The extension wires its real `describeImage`
316
+ * into this helper in its `tool_result` handler.
317
+ *
318
+ * When at least one description is inserted, also strips pi core's
319
+ * {@link NON_VISION_IMAGE_NOTE} from text blocks — the note (appended by the
320
+ * read tool for non-vision models) would otherwise contradict the inserted
321
+ * description. Text blocks are reassigned (not mutated in place); the input
322
+ * array is left untouched.
323
+ *
324
+ * Returns a new array and `changed: false` when there were no images, so
325
+ * callers can short-circuit and avoid mutating pi's stored result unnecessarily.
326
+ */
327
+ export async function insertImageDescriptions(
328
+ content: readonly (TextContent | ImageContent)[] | undefined,
329
+ describe: (img: ExtractedImage) => Promise<string>,
330
+ ): Promise<ReplacedContent> {
331
+ if (!Array.isArray(content)) {
332
+ return { content: [], changed: false };
333
+ }
334
+ const next: (TextContent | ImageContent)[] = [];
335
+ let changed = false;
336
+ for (const block of content) {
337
+ const img = extractImageFromBlock(block);
338
+ if (!img) {
339
+ next.push(block);
340
+ continue;
341
+ }
342
+ const description = await describe(img);
343
+ next.push({ type: "text", text: description } satisfies TextContent);
344
+ next.push(block);
345
+ changed = true;
346
+ }
347
+ if (!changed) {
348
+ return { content: next, changed };
349
+ }
350
+ // We inserted at least one description. Strip the read tool's
351
+ // "[Current model does not support images...]" note from text blocks — it
352
+ // contradicts the description we just inserted ("image will be omitted" vs.
353
+ // the description that follows) and confuses the model.
354
+ for (let i = 0; i < next.length; i++) {
355
+ const block = next[i];
356
+ if (block.type === "text" && typeof block.text === "string" && block.text.includes(NON_VISION_IMAGE_NOTE)) {
357
+ next[i] = { type: "text", text: stripNonVisionImageNote(block.text) } satisfies TextContent;
358
+ }
359
+ }
360
+ return { content: next, changed };
361
+ }
package/vision-handoff.ts CHANGED
@@ -9,12 +9,18 @@
9
9
  *
10
10
  * Pipeline (provider-agnostic via @earendil-works/pi-ai's complete()):
11
11
  * before_agent_start → warm the description cache for attached images
12
- * before_provider_requestswap image blocks in the payload for text
12
+ * tool_result (read) describe read-tool images and INSERT the description
13
+ * as a text block before each image, keeping the image so the TUI still
14
+ * renders it (kitty). pi-ai later strips the image for non-vision models,
15
+ * leaving the description text for the model.
16
+ * before_provider_request → swap remaining image blocks in the payload for
17
+ * text (catches user-attached images for vision-capable handoff targets)
13
18
  *
14
- * Image blocks are detected by shape across the three request formats pi uses:
19
+ * Image blocks are detected by shape across the four formats pi uses:
15
20
  * openai-completions: { type: "image_url", image_url: { url: "data:..." } }
16
21
  * openai-responses: { type: "input_image", image_url: "data:..." }
17
22
  * anthropic-messages: { type: "image", source: { type: "base64", media_type, data } }
23
+ * pi-ai internal: { type: "image", data, mimeType } ← read tool / ToolResultEvent
18
24
  *
19
25
  * Descriptions are cached per image hash (LRU, size = config.cacheMax) so the
20
26
  * swap is instant by the time before_provider_request fires.
@@ -33,10 +39,12 @@ import {
33
39
  IMAGE_PLACEHOLDER_SUFFIX,
34
40
  extractImageFromBlock,
35
41
  formatModelRef,
42
+ insertImageDescriptions,
36
43
  isVisionModel,
37
44
  makeReplacementText,
38
45
  parseModelRef,
39
46
  readConfig,
47
+ truncateDescription,
40
48
  writeConfig,
41
49
  type VisionHandoffConfig,
42
50
  } from "./src/index.js";
@@ -46,6 +54,9 @@ const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_P
46
54
 
47
55
  let config: VisionHandoffConfig = readConfig();
48
56
 
57
+ /** User prompt for the current agent turn, captured from before_agent_start. */
58
+ let pendingTurnPrompt: string | null = null;
59
+
49
60
  const visionCache = new Map<string, Promise<string>>();
50
61
  let visionModelCache: { ref: string; model: Model<Api> } | null = null;
51
62
  let visionModelUnresolvedRef: string | null = null;
@@ -134,7 +145,17 @@ async function describeImage(
134
145
  .join("\n")
135
146
  .trim();
136
147
  if (!description) return UNAVAILABLE;
137
- return `${IMAGE_PLACEHOLDER_PREFIX}${description}${IMAGE_PLACEHOLDER_SUFFIX}`;
148
+ // The full description lives in the stored tool-result content; the read
149
+ // tool's native collapse (ctrl+o) handles compactness — collapsed shows
150
+ // the image only, expanded shows the full description + image. Only apply
151
+ // a hard line cap when the user opts in (maxDescriptionLines > 0); by
152
+ // default (0) the description is unbounded so ctrl+o can expand to all
153
+ // of it and the model receives the complete context.
154
+ const { text: final } =
155
+ cfg.maxDescriptionLines && cfg.maxDescriptionLines > 0
156
+ ? truncateDescription(description, cfg.maxDescriptionLines)
157
+ : { text: description };
158
+ return `${IMAGE_PLACEHOLDER_PREFIX}${final}${IMAGE_PLACEHOLDER_SUFFIX}`;
138
159
  } catch {
139
160
  return UNAVAILABLE;
140
161
  } finally {
@@ -196,11 +217,17 @@ export default function (pi: ExtensionAPI) {
196
217
  // Reload in case the user edited the config on disk from another session.
197
218
  config = readConfig();
198
219
  visionModelCache = null;
220
+ pendingTurnPrompt = null;
199
221
  });
200
222
 
201
223
  pi.on("before_agent_start", async (event, ctx) => {
202
224
  if (!isConfigured(config)) return;
203
225
  if (!isHandoffTarget(ctx.model, config)) return;
226
+
227
+ // Capture this turn's user prompt so read-tool images are described in
228
+ // the same context. Fires before any tool_result for the turn.
229
+ pendingTurnPrompt = event.prompt || "";
230
+
204
231
  const images = event.images;
205
232
  if (!images || images.length === 0) return;
206
233
 
@@ -235,6 +262,29 @@ export default function (pi: ExtensionAPI) {
235
262
  return payload;
236
263
  });
237
264
 
265
+ pi.on("tool_result", async (event, ctx) => {
266
+ if (!isConfigured(config)) return;
267
+ if (!isHandoffTarget(ctx.model, config)) return;
268
+ if (event.toolName !== "read") return;
269
+
270
+ const content = event.content;
271
+ if (!Array.isArray(content)) return;
272
+ // Skip the async work entirely when there is nothing to describe.
273
+ if (!content.some((c) => extractImageFromBlock(c))) return;
274
+
275
+ const visionModel = resolveVisionModel(ctx.modelRegistry, config.visionModel!);
276
+ if (!visionModel) {
277
+ notifyUnresolvedVisionModel(ctx, config.visionModel!);
278
+ return;
279
+ }
280
+
281
+ const { content: next, changed } = await insertImageDescriptions(content, (img) =>
282
+ describeImage(img.data, img.mimeType, pendingTurnPrompt ?? "", visionModel, ctx.modelRegistry, config),
283
+ );
284
+ if (!changed) return;
285
+ return { content: next };
286
+ });
287
+
238
288
  pi.on("model_select", (event, ctx) => {
239
289
  if (!ctx.hasUI) return;
240
290
  if (!isConfigured(config)) return;
@@ -464,7 +514,7 @@ function showStatus(ctx: ExtensionCommandContext): void {
464
514
  lines.push(`Vision model: ${config.visionModel ?? "(none — pick one with /vision-handoff)"}`);
465
515
  lines.push(`Auto handoff (non-vision models): ${config.autoHandoff ? "on" : "off"}`);
466
516
  lines.push(`Handoff targets (explicit): ${config.handoffModels.length ? config.handoffModels.join(", ") : "(none)"}`);
467
- lines.push(`maxTokens: ${config.maxTokens} · cacheMax: ${config.cacheMax}`);
517
+ lines.push(`maxTokens: ${config.maxTokens} · cacheMax: ${config.cacheMax} · maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
468
518
 
469
519
  const model = ctx.model;
470
520
  let active = false;