pi-vision-handoff 0.4.3 โ†’ 0.5.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
@@ -43,6 +43,7 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
43
43
  - **๐Ÿง  Automatic targets** โ€” by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
44
44
  - **๐Ÿ—‚๏ธ Explicit overrides** โ€” force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
45
45
  - **โšก Pre-warmed at paste-enter** โ€” the moment you press enter, `before_agent_start` scans the prompt for pasted clipboard image temp-file paths (pi writes pasted images to `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the path as text), reads them, and kicks off the ONE batched vision call concurrent with the agent's first response โ€” so by the time the agent reads the files, they're already cache hits.
46
+ - **๐Ÿš€ Paste-time prewarm (opt-in)** โ€” `prewarmPastedImages` (off by default) wraps the editor so pasted clipboard images are described the *instant* their path lands in the prompt โ€” before you hit enter โ€” not at submit. The vision call starts concurrent with you typing your question. Tradeoff: the description is generated without your typed question as context (the question usually isn't entered yet at paste time), and a paste-then-abandon wastes one vision call. TUI only; inactive if another extension replaces the editor. Toggle with `/vision-handoff prewarm on`.
46
47
  - **๐Ÿ›ก๏ธ Graceful degradation** โ€” no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
47
48
  - **๐Ÿ“Š Usage reporting** โ€” every real describer call reports model + tokens (and Neuralwatt energy/cost when the vision model is a Neuralwatt model), via `pi.appendEntry` + a `vision-handoff:usage` event for live consumers.
48
49
  - **๐Ÿ”ง Tunable** โ€” cap description length (`maxDescriptionLines`; unbounded by default, so `read`'s native `ctrl+o` collapse handles compactness) and cache size, in the config file.
@@ -59,6 +60,7 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
59
60
  | `/vision-handoff status` | Show current config + whether handoff is active for the current model |
60
61
  | `/vision-handoff enable` / `disable` | Master switch (keeps your configured model) |
61
62
  | `/vision-handoff auto on` / `off` | Toggle automatic handoff for all non-vision models |
63
+ | `/vision-handoff prewarm on` / `off` | Toggle paste-time prewarm (opt-in, off by default) |
62
64
  | `/vision-handoff add ollama/llava:13b` | Force handoff for an extra model |
63
65
  | `/vision-handoff remove ollama/llava:13b` | Stop forcing handoff for a model |
64
66
  | `/vision-handoff clear` | Clear the configured vision model |
@@ -74,6 +76,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
74
76
  "visionModel": "openai/gpt-4o",
75
77
  "autoHandoff": true,
76
78
  "handoffModels": ["ollama/llava:13b"],
79
+ "prewarmPastedImages": false,
77
80
  "maxTokens": null,
78
81
  "cacheMax": 50,
79
82
  "maxDescriptionLines": 0,
@@ -88,6 +91,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
88
91
  | `visionModel` | `null` | The describer, as `provider/id`. `null` = not configured (handoff inactive). |
89
92
  | `autoHandoff` | `true` | Apply handoff to every model whose `input` does not include `image`. |
90
93
  | `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
94
+ | `prewarmPastedImages` | `false` | **Opt-in paste-time prewarm.** When `true`, a custom editor wrapper describes pasted clipboard images the instant their temp-file path lands in the prompt (pre-submit), instead of at submit. Trades a bit of description quality (the description is generated without your typed question as context, since the question usually isn't entered yet at paste time) and a speculative vision call on paste-then-abandon, for earlier prewarm. TUI mode only; inactive if another extension has replaced the editor. |
91
95
  | `maxTokens` | _(unset = model max, clamped to context window)_ | Cap on a single description's output. `null`/unset = use the vision model's declared max output (`model.maxTokens`), clamped so `maxTokens + 8192 <= contextWindow` (a model whose declared max equals its full context window would otherwise be rejected with a 400). Set a number only to cap cost/latency. A truncation is always surfaced via a `[... description truncated โ€ฆ]` marker when the model hits the limit, so a cut-off description is never mistaken for complete. |
92
96
  | `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
93
97
  | `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. |
@@ -142,6 +146,26 @@ memoizes promises; all `load()` calls in the same execution frame coalesce
142
146
  into ONE batched vision call, dispatched via `setImmediate` after the poll
143
147
  phase (so reads completing together batch instead of splitting into N calls).
144
148
 
149
+ With `prewarmPastedImages` on (opt-in), an even earlier stage runs:
150
+
151
+ โ†’ editor onChange (paste-time, pre-submit)
152
+ โ€ข pi has no "image pasted" event; the earliest signal is the editor's
153
+ `onChange(text)`, which fires when pi's `handleClipboardImagePaste`
154
+ inserts the temp-file path via `insertTextAtCursor`
155
+ โ€ข a wrapping CustomEditor chains `onChange` (pi assigns its own
156
+ `onChange` โ€” for bash-mode border tracking โ€” after construction, so
157
+ the wrapper captures it via an accessor and runs it alongside an
158
+ observer that `diffPrewarmPaths`s new `pi-clipboard-*` paths and
159
+ `loadDescription()`s them at paste-time โ€” the vision call starts
160
+ before you hit enter; `before_agent_start`'s same-path prewarm is
161
+ then a cache hit
162
+ โ€ข it does NOT override `handleInput`, so keybindings + `ctrl+v` paste
163
+ are untouched; TUI only, and skipped when another extension has
164
+ replaced the editor (installing over one would break paste, since
165
+ pi wires paste-image to the outermost editor only)
166
+
167
+ The default submit-time pipeline below runs regardless:
168
+
145
169
  โ†’ before_agent_start
146
170
  โ€ข captures this turn's user prompt (shared by every image description)
147
171
  โ€ข binds turn context (vision model + abort signal) to the loader
@@ -346,6 +370,7 @@ pnpm lint:dead # Dead code detection (knip)
346
370
  โ”‚ โ”œโ”€โ”€ dataloader.ts # DescriptionLoader โ€” DataLoader-batched descriptions (Disposable)
347
371
  โ”‚ โ”œโ”€โ”€ describer.ts # Vision describer calls (runBatch / describeSingle) with `using` resource guards
348
372
  โ”‚ โ”œโ”€โ”€ image.ts # Image hashing, MIME sniffing, clipboard-path reading
373
+ โ”‚ โ”œโ”€โ”€ prewarm-editor.ts # Opt-in paste-time prewarm CustomEditor wrapper (chains onChange)
349
374
  โ”‚ โ”œโ”€โ”€ dispose.ts # `Disposable` guard factories for `using` (fetch interceptor, timer, abort wire)
350
375
  โ”‚ โ”œโ”€โ”€ usage.ts # Describer usage + Neuralwatt energy capture, fetch interceptor
351
376
  โ”‚ โ””โ”€โ”€ vision-model-selector.ts # Interactive picker TUI component
@@ -355,7 +380,7 @@ pnpm lint:dead # Dead code detection (knip)
355
380
  โ”‚ โ”œโ”€โ”€ vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
356
381
  โ”‚ โ”œโ”€โ”€ dataloader.test.ts # Batch coalescing, memoization, failure eviction, Disposable reset
357
382
  โ”‚ โ”œโ”€โ”€ describer.test.ts # stopReason handling (length โ†’ truncation marker, aborted/error)
358
- โ”‚ โ”œโ”€โ”€ image.test.ts # MIME sniffing, clipboard-path confinement, file reading
383
+ โ”‚ โ”œโ”€โ”€ image.test.ts # MIME sniffing, clipboard-path confinement, file reading, diffPrewarmPaths
359
384
  โ”‚ โ””โ”€โ”€ dispose.test.ts # `using` guards: fetch refcount, timeout, abort-wire propagation
360
385
  โ”œโ”€โ”€ package.json
361
386
  โ”œโ”€โ”€ tsconfig.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.4.3",
3
+ "version": "0.5.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/dataloader.ts CHANGED
@@ -91,19 +91,68 @@ export class DescriptionLoader implements Disposable {
91
91
  private dispatchScheduled = false;
92
92
  private turnModelRegistry: ModelRegistry | null = null;
93
93
  private turnVisionModel: Model<Api> | null = null;
94
- private turnSignal: AbortSignal | undefined;
94
+ /** Turn-level abort controller. In-flight describer batches (`runBatch`)
95
+ * are wired to `turnAbortController.signal` so a user cancel (ESC) aborts
96
+ * them EVEN WHEN the batch was dispatched before the run's live abort
97
+ * signal existed โ€” the `before_agent_start` / paste-time prewarm case,
98
+ * where `ctx.signal` is `undefined` (the agent run hasn't started yet).
99
+ * The run's live signal, once it arrives via `bindTurnContext`, is forwarded
100
+ * into `turnAbortController.abort()`. Because `runBatch` holds a reference
101
+ * to this controller's signal (not a snapshot of `ctx.signal`), a batch
102
+ * that started during prewarm becomes abortable the instant a later
103
+ * `bindTurnContext` (from `tool_result`/`context`) brings the live signal.
104
+ * Reset per turn via {@link resetTurnAbort} so a previous turn's cancel
105
+ * can't poison the next turn's prewarm. */
106
+ private turnAbortController = new AbortController();
107
+ /** The run signal currently forwarded into {@link turnAbortController}, so
108
+ * the abort listener is attached at most once per signal (the same signal
109
+ * is bound by every `tool_result`/`context` event in a turn). */
110
+ private wiredSignal: AbortSignal | undefined;
95
111
  private pendingTurnPrompt = "";
96
112
 
97
113
  constructor(private readonly deps: LoaderDeps) {}
98
114
 
99
- /** Bind the turn context (model registry, resolved vision model, abort signal)
100
- * for the loader's next dispatch. Called from every handler that may trigger
101
- * `loadDescription()`. */
115
+ /** Bind the turn context (model registry, resolved vision model, abort
116
+ * signal) for the loader's next dispatch. Called from every handler that
117
+ * may trigger `loadDescription()`.
118
+ *
119
+ * The abort signal is forwarded into the loader's {@link turnAbortController}
120
+ * rather than stored directly. Storing `ctx.signal` directly would leave a
121
+ * prewarm-dispatched batch (started in `before_agent_start`, where
122
+ * `ctx.signal` is `undefined` because the run hasn't started) with no abort
123
+ * wire โ€” ESC couldn't cancel it, so the `tool_result` handler would only
124
+ * discard the result AFTER the vision call ran to completion. Forwarding
125
+ * the live signal into a stable, loader-owned controller lets an in-flight
126
+ * prewarm batch be aborted the moment the live signal arrives. */
102
127
  bindTurnContext(ctx: { modelRegistry: ModelRegistry; signal?: AbortSignal }): void {
103
128
  this.turnModelRegistry = ctx.modelRegistry;
104
- this.turnSignal = ctx.signal;
105
129
  const resolved = this.deps.resolveVisionModel(ctx.modelRegistry, this.deps.getConfig().visionModel!);
106
130
  if (resolved) this.turnVisionModel = resolved;
131
+ const signal = ctx.signal;
132
+ if (!signal) return;
133
+ if (signal.aborted) {
134
+ this.turnAbortController.abort();
135
+ this.wiredSignal = signal;
136
+ return;
137
+ }
138
+ if (signal === this.wiredSignal) return; // already forwarded this signal
139
+ this.wiredSignal = signal;
140
+ signal.addEventListener("abort", () => this.turnAbortController.abort(), { once: true });
141
+ }
142
+
143
+ /** Reset the turn-level abort controller for a fresh turn. Call at turn
144
+ * boundaries (`before_agent_start`, paste-time prewarm) so a previous turn's
145
+ * cancel doesn't leave {@link turnAbortController} aborted โ€” which would
146
+ * make every subsequent dispatch short-circuit to UNAVAILABLE. A
147
+ * non-aborted controller is reused (avoids orphaning an in-flight paste
148
+ * prewarm holding its signal); only an aborted one is replaced. `wiredSignal`
149
+ * is always cleared so the next live signal re-wires. Safe at a real turn
150
+ * boundary, where the prior turn's batch has settled. */
151
+ resetTurnAbort(): void {
152
+ if (this.turnAbortController.signal.aborted) {
153
+ this.turnAbortController = new AbortController();
154
+ }
155
+ this.wiredSignal = undefined;
107
156
  }
108
157
 
109
158
  /** Capture this turn's user prompt so every image in the turn is described in
@@ -160,7 +209,9 @@ export class DescriptionLoader implements Disposable {
160
209
  this.dispatchScheduled = false;
161
210
  this.turnModelRegistry = null;
162
211
  this.turnVisionModel = null;
163
- this.turnSignal = undefined;
212
+ // Fresh controller on session reset (no in-flight batch to orphan).
213
+ this.turnAbortController = new AbortController();
214
+ this.wiredSignal = undefined;
164
215
  this.pendingTurnPrompt = "";
165
216
  }
166
217
 
@@ -194,7 +245,7 @@ export class DescriptionLoader implements Disposable {
194
245
  for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
195
246
  return;
196
247
  }
197
- if (this.turnSignal?.aborted) {
248
+ if (this.turnAbortController.signal.aborted) {
198
249
  for (const key of batch.keys) this.cache.delete(key);
199
250
  for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
200
251
  return;
@@ -209,7 +260,7 @@ export class DescriptionLoader implements Disposable {
209
260
  this.turnModelRegistry,
210
261
  cfg,
211
262
  this.deps,
212
- this.turnSignal,
263
+ this.turnAbortController.signal,
213
264
  );
214
265
  // Build per-hash results, then fan them out to every callback. This reaches
215
266
  // duplicate-hash callbacks that a key-indexed loop would have skipped (and
package/src/image.ts CHANGED
@@ -272,3 +272,20 @@ export function findClipboardImagePaths(prompt: string): string[] {
272
272
  }
273
273
  return [...paths];
274
274
  }
275
+
276
+ /** Diff the clipboard image paths in `text` against `known`, returning those
277
+ * that are newly appeared (not yet seen). Used by the paste-time prewarm
278
+ * editor to prewarm only newly-pasted paths on each text change, without
279
+ * re-reading already-seen ones. `findClipboardImagePaths` already dedups
280
+ * within a single text, so each returned path is unique and seen at most
281
+ * once. Returns [] when `text` holds no clipboard paths (e.g. ordinary
282
+ * typing) โ€” so the editor's per-keystroke cost when the opt-in is on is one
283
+ * regex scan that almost always yields nothing. */
284
+ export function diffPrewarmPaths(text: string, known: Set<string>): string[] {
285
+ const paths = findClipboardImagePaths(text);
286
+ const newPaths: string[] = [];
287
+ for (const p of paths) {
288
+ if (!known.has(p)) newPaths.push(p);
289
+ }
290
+ return newPaths;
291
+ }
package/src/index.ts CHANGED
@@ -136,6 +136,18 @@ export interface VisionHandoffConfig {
136
136
  autoHandoff: boolean;
137
137
  /** Extra "provider/id" refs that should ALSO receive handoff (e.g. weak vision models). */
138
138
  handoffModels: string[];
139
+ /** Opt-in: when true, the editor is wrapped at session start to describe
140
+ * pasted clipboard images the instant their temp-file path lands in the
141
+ * prompt (pre-submit), instead of waiting for submit. Trades a bit of
142
+ * description quality (the description is generated without the user's
143
+ * typed question as context, since the question usually isn't entered yet
144
+ * at paste time) and a speculative vision call on paste-then-abandon, for
145
+ * earlier prewarm. Off by default โ€” submit-time prewarm
146
+ * (before_agent_start) still covers this case when off. TUI mode only, and
147
+ * inactive when another extension has replaced the editor (installing over
148
+ * a custom editor would break clipboard paste, since pi wires paste-image
149
+ * to the outermost editor only). */
150
+ prewarmPastedImages: boolean;
139
151
  /** Max output tokens for a single description. `undefined` (default) = use the
140
152
  * vision model's declared max output (`model.maxTokens`) as the cap โ€” the
141
153
  * highest the model supports โ€” so the "be exhaustive" prompt isn't defeated
@@ -172,6 +184,7 @@ export const DEFAULT_CONFIG: VisionHandoffConfig = {
172
184
  visionModel: null,
173
185
  autoHandoff: true,
174
186
  handoffModels: [],
187
+ prewarmPastedImages: false,
175
188
  maxTokens: undefined,
176
189
  cacheMax: DEFAULT_CACHE_MAX,
177
190
  maxDescriptionLines: DEFAULT_MAX_DESCRIPTION_LINES,
@@ -221,6 +234,7 @@ export function normalizeConfig(raw: unknown): VisionHandoffConfig {
221
234
  .map((m) => m.trim())
222
235
  .filter((m) => m && parseModelRef(m));
223
236
  }
237
+ if (typeof obj.prewarmPastedImages === "boolean") base.prewarmPastedImages = obj.prewarmPastedImages;
224
238
  // maxTokens: optional. undefined (default) = no artificial cap. Only set when
225
239
  // a valid positive finite number is given; any other value leaves it unset.
226
240
  if (typeof obj.maxTokens === "number" && Number.isFinite(obj.maxTokens) && obj.maxTokens > 0) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Paste-time prewarm editor wrapper.
3
+ *
4
+ * pi has no "image pasted into the prompt" event โ€” the extension event bus
5
+ * only surfaces input at submit time (`input` / `before_agent_start`). The
6
+ * earliest observable signal that a clipboard image has landed in the prompt
7
+ * is the editor's own `onChange(text)`, which fires when pi's
8
+ * `handleClipboardImagePaste` inserts the temp-file path
9
+ * (`<tmpdir>/pi-clipboard-<uuid>.<ext>`) via `insertTextAtCursor` โ€” still
10
+ * pre-submit. This wrapper installs a `CustomEditor` that observes `onChange`
11
+ * to prewarm the describer for newly-pasted clipboard image paths the instant
12
+ * they appear, so the vision call starts before the user hits enter (true
13
+ * paste-time) instead of at `before_agent_start` (submit-time).
14
+ *
15
+ * It does NOT override `handleInput`, so every keybinding and pi's clipboard
16
+ * paste flow (`ctrl+v` โ†’ `onPasteImage` โ†’ `handleClipboardImagePaste`) is
17
+ * untouched. We only observe the result via `onChange`.
18
+ *
19
+ * Why chain `onChange` via an accessor: pi's `setCustomEditorComponent` does
20
+ * `newEditor.onChange = this.defaultEditor.onChange` after construction,
21
+ * clobbering any `onChange` set in the constructor โ€” and that default
22
+ * `onChange` tracks bash-mode for the editor border, so it must keep running.
23
+ * The accessor captures pi's assignment and runs it alongside our observer.
24
+ * (The `Editor` base never reassigns `onChange` itself, so the captured
25
+ * reference is stable for the editor's lifetime.)
26
+ *
27
+ * Opt-in via `VisionHandoffConfig.prewarmPastedImages`. When off (or the
28
+ * active model isn't a handoff target), the observer short-circuits before
29
+ * the path regex runs, so overhead is one boolean check per text change.
30
+ */
31
+
32
+ import { CustomEditor, type KeybindingsManager, type ModelRegistry } from "@earendil-works/pi-coding-agent";
33
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
34
+ import { diffPrewarmPaths } from "./image.js";
35
+
36
+ /** Dependencies the editor can't own itself (held by the engine, captured at
37
+ * session start). `modelRegistry` is fixed for the editor's (session)
38
+ * lifetime; `shouldPrewarm` and `prewarmPath` read live config/model state so
39
+ * toggling the opt-in or switching models takes effect without reinstalling. */
40
+ export interface PrewarmEditorDeps {
41
+ modelRegistry: ModelRegistry;
42
+ /** Live gate: paste-time prewarm runs only when this returns true. */
43
+ shouldPrewarm: () => boolean;
44
+ /** Prewarm one clipboard image path (read + resize-match + loadDescription). */
45
+ prewarmPath: (path: string, modelRegistry: ModelRegistry) => void;
46
+ }
47
+
48
+ /** A `CustomEditor` that observes `onChange` to prewarm pasted clipboard image
49
+ * paths at paste-time. See the module doc for the `onChange` chaining. */
50
+ export class PrewarmEditor extends CustomEditor {
51
+ private readonly knownPaths = new Set<string>();
52
+ private piOnChange: ((text: string) => void) | undefined;
53
+ private readonly shouldPrewarm: () => boolean;
54
+ private readonly prewarmPath: (path: string, modelRegistry: ModelRegistry) => void;
55
+ private readonly modelRegistry: ModelRegistry;
56
+
57
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PrewarmEditorDeps) {
58
+ super(tui, theme, keybindings);
59
+ this.shouldPrewarm = deps.shouldPrewarm;
60
+ this.prewarmPath = deps.prewarmPath;
61
+ this.modelRegistry = deps.modelRegistry;
62
+
63
+ // Stable wrapper: calls pi's onChange (bash-mode border tracking) then our
64
+ // observer. Returned on every read so the Editor's `this.onChange(...)` calls
65
+ // all hit the same function.
66
+ const wrapper = (text: string): void => {
67
+ this.piOnChange?.(text);
68
+ this.handleTextChange(text);
69
+ };
70
+ // pi assigns defaultEditor.onChange to this property after construction;
71
+ // capture it via the setter, expose the wrapper via the getter.
72
+ Object.defineProperty(this, "onChange", {
73
+ configurable: true,
74
+ enumerable: true,
75
+ get: () => wrapper,
76
+ set: (fn: ((text: string) => void) | undefined) => {
77
+ this.piOnChange = fn;
78
+ },
79
+ });
80
+ }
81
+
82
+ /** Scan the editor text for clipboard image paths not yet seen this prompt
83
+ * and prewarm each. Clears the known set when the editor empties (after
84
+ * submit) so a later paste (fresh uuid) re-prewarms and the set can't grow
85
+ * unbounded. */
86
+ private handleTextChange(text: string): void {
87
+ if (!this.shouldPrewarm()) return;
88
+ const newPaths = diffPrewarmPaths(text, this.knownPaths);
89
+ for (const p of newPaths) {
90
+ this.knownPaths.add(p);
91
+ this.prewarmPath(p, this.modelRegistry);
92
+ }
93
+ if (text.length === 0) this.knownPaths.clear();
94
+ }
95
+ }
package/vision-handoff.ts CHANGED
@@ -53,6 +53,7 @@ import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloade
53
53
  import { imageHash, findClipboardImagePaths, readImageBuffer, resolvePrewarmImage } from "./src/image.js";
54
54
  import { resizeImage } from "@earendil-works/pi-coding-agent";
55
55
  import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
56
+ import { PrewarmEditor } from "./src/prewarm-editor.js";
56
57
 
57
58
  let config: VisionHandoffConfig = readConfig();
58
59
 
@@ -70,6 +71,15 @@ let lastDescriberError: string | null = null;
70
71
  * Cleared on `session_start`. */
71
72
  const warnedHashes = new Set<string>();
72
73
 
74
+ /** Current model, tracked so the paste-time prewarm gate can skip prewarming
75
+ * when the active model is vision-capable (handoff won't run โ†’ a prewarm
76
+ * would be a wasted vision call). Updated in session_start and model_select. */
77
+ let currentModel: Model<Api> | undefined | null;
78
+
79
+ /** Whether the paste-time prewarm editor is installed this session. False in
80
+ * non-TUI modes or when another extension already replaced the editor. */
81
+ let editorInstalled = false;
82
+
73
83
  let visionModelCache: { ref: string; model: Model<Api> } | null = null;
74
84
  let visionModelUnresolvedRef: string | null = null;
75
85
 
@@ -112,6 +122,67 @@ function isHandoffTarget(
112
122
  return false;
113
123
  }
114
124
 
125
+ /** Whether paste-time prewarm should fire for a text change right now: the
126
+ * opt-in flag is on, handoff is configured, and the active model is a handoff
127
+ * target (so the prewarmed description will actually be consumed โ€” a
128
+ * vision-capable model needs no handoff, so prewarming would waste a call). */
129
+ function shouldPrewarmPaste(): boolean {
130
+ return config.prewarmPastedImages && isConfigured(config) && isHandoffTarget(currentModel, config);
131
+ }
132
+
133
+ /** Prewarm one pasted clipboard image path through the dataloader at paste
134
+ * time. Mirrors the before_agent_start clipboard prewarm, but binds only the
135
+ * model registry (no turn signal โ€” the agent is idle at paste time) and
136
+ * resets the turn prompt to "" so a stale previous-turn prompt can't leak
137
+ * into the description. The user's question isn't typed yet at paste time, so
138
+ * the description is generated without question context (the documented
139
+ * tradeoff of the opt-in). If the user submits before this dispatch fires,
140
+ * before_agent_start overwrites the prompt โ€” a benign bonus, not a bug. */
141
+ function prewarmClipboardPath(path: string, modelRegistry: ModelRegistry): void {
142
+ const read = readImageBuffer(path);
143
+ if (!read) return;
144
+ resolvePrewarmImage(read.buf, read.mimeType, resizeImage)
145
+ .then((img) => {
146
+ if (!img) return;
147
+ // Paste happens while the agent is idle (no run โ†’ no live signal).
148
+ // Reset the turn-abort controller so a previous turn's ESC doesn't leave
149
+ // it aborted and short-circuit this prewarm; the next submit's
150
+ // `before_agent_start` resets again. The prewarm batch uses the loader's
151
+ // controller and becomes abortable once a run starts and binds a live
152
+ // signal.
153
+ loader.resetTurnAbort();
154
+ loader.setPendingTurnPrompt("");
155
+ loader.bindTurnContext({ modelRegistry });
156
+ loader.loadDescription(img).catch(() => {});
157
+ })
158
+ .catch(() => {});
159
+ }
160
+
161
+ /** Install the paste-time prewarm editor wrapper for this session. TUI only,
162
+ * and only when no other extension has replaced the editor โ€” installing over
163
+ * a custom editor would clobber its input handling and break clipboard paste
164
+ * (pi wires paste-image to the outermost editor only). When a custom editor
165
+ * is present, paste-time prewarm is unavailable; submit-time prewarm
166
+ * (before_agent_start) still covers clipboard paths. */
167
+ function installPrewarmEditor(ctx: ExtensionContext): void {
168
+ if (ctx.mode !== "tui") {
169
+ editorInstalled = false;
170
+ return;
171
+ }
172
+ if (ctx.ui.getEditorComponent()) {
173
+ editorInstalled = false;
174
+ return;
175
+ }
176
+ editorInstalled = true;
177
+ ctx.ui.setEditorComponent((_tui, theme, keybindings) =>
178
+ new PrewarmEditor(_tui, theme, keybindings, {
179
+ modelRegistry: ctx.modelRegistry,
180
+ shouldPrewarm: shouldPrewarmPaste,
181
+ prewarmPath: prewarmClipboardPath,
182
+ }),
183
+ );
184
+ }
185
+
115
186
  function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
116
187
  if (visionModelUnresolvedRef === ref) return;
117
188
  visionModelUnresolvedRef = ref;
@@ -169,19 +240,33 @@ export default function (pi: ExtensionAPI) {
169
240
  }
170
241
  };
171
242
 
172
- pi.on("session_start", async () => {
243
+ pi.on("session_start", async (_event, ctx) => {
173
244
  // Reload in case the user edited the config on disk from another session.
174
245
  config = readConfig();
175
246
  visionModelCache = null;
176
247
  visionModelUnresolvedRef = null;
177
248
  warnedHashes.clear();
178
249
  loader.reset();
250
+ currentModel = ctx.model;
251
+ installPrewarmEditor(ctx);
179
252
  });
180
253
 
181
254
  pi.on("before_agent_start", async (event, ctx) => {
182
255
  if (!isConfigured(config)) return;
183
256
  if (!isHandoffTarget(ctx.model, config)) return;
184
257
 
258
+ // Fresh turn โ†’ fresh turn-abort controller. `before_agent_start` fires
259
+ // BEFORE the agent run starts, so `ctx.signal` is undefined here (the run's
260
+ // abort signal doesn't exist yet โ€” it's created in `agent.prompt()` โ†’
261
+ // `runWithLifecycle`, which runs AFTER this event). The prewarm below
262
+ // dispatches describer batches now, and they must be abortable once the
263
+ // run's live signal arrives โ€” so reset the loader's turn-abort controller
264
+ // here, and let the later `tool_result`/`context` binds forward the live
265
+ // signal into it. Without this reset, a previous turn's ESC would leave
266
+ // the controller aborted and every dispatch would short-circuit to
267
+ // UNAVAILABLE.
268
+ loader.resetTurnAbort();
269
+
185
270
  // Capture this turn's user prompt so every image in the turn โ€” attached or
186
271
  // read via the read tool โ€” is described in the same request context.
187
272
  loader.setPendingTurnPrompt(event.prompt || "");
@@ -405,6 +490,7 @@ export default function (pi: ExtensionAPI) {
405
490
  });
406
491
 
407
492
  pi.on("model_select", (event, ctx) => {
493
+ currentModel = event.model;
408
494
  if (!ctx.hasUI) return;
409
495
  if (!isConfigured(config)) return;
410
496
  const model = event.model;
@@ -420,7 +506,7 @@ export default function (pi: ExtensionAPI) {
420
506
  pi.registerCommand("vision-handoff", {
421
507
  description: HANDOFF_COMMAND_DESCRIPTION,
422
508
  getArgumentCompletions(prefix: string) {
423
- const subcommands = ["select", "model", "status", "enable", "disable", "auto", "thinking", "add", "remove", "clear", "help"];
509
+ const subcommands = ["select", "model", "status", "enable", "disable", "auto", "thinking", "prewarm", "add", "remove", "clear", "help"];
424
510
  const matches = subcommands.filter((s) => s.startsWith(prefix));
425
511
  return matches.length > 0 ? matches.map((s) => ({ value: s, label: s })) : null;
426
512
  },
@@ -454,6 +540,8 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
454
540
  " /vision-handoff auto <on|off> Toggle automatic handoff for all non-vision models",
455
541
  " /vision-handoff thinking <off|minimal|low|medium|high|xhigh>",
456
542
  " Set the vision describer's thinking effort (off = disabled)",
543
+ " /vision-handoff prewarm <on|off>",
544
+ " Toggle describing pasted images at paste-time (opt-in, off by default)",
457
545
  " /vision-handoff add <p/id> Force handoff for an extra model",
458
546
  " /vision-handoff remove <p/id> Stop forcing handoff for a model",
459
547
  " /vision-handoff clear Clear the configured vision model",
@@ -463,6 +551,7 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
463
551
  "Mechanism: before_agent_start warms a description cache; tool_result loads",
464
552
  " read images through a dataloader (one batched vision call); context swaps",
465
553
  " image blocks in the payload for the cached text description.",
554
+ " prewarm on wraps the editor to describe pasted images at paste-time.",
466
555
  ].join("\n"),
467
556
  "info",
468
557
  );
@@ -504,6 +593,11 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
504
593
  return;
505
594
  }
506
595
 
596
+ if (subcommand === "prewarm") {
597
+ handlePrewarmSubcommand(ctx, rest);
598
+ return;
599
+ }
600
+
507
601
  if (subcommand === "clear") {
508
602
  updateConfig(
509
603
  ctx,
@@ -629,6 +723,30 @@ function handleThinkingSubcommand(ctx: ExtensionCommandContext, rest: string): v
629
723
  );
630
724
  }
631
725
 
726
+ /** Handle `/vision-handoff prewarm <on|off>` โ€” toggle paste-time prewarm. */
727
+ function handlePrewarmSubcommand(ctx: ExtensionCommandContext, rest: string): void {
728
+ const value = rest.trim().toLowerCase();
729
+ if (!value) {
730
+ ctx.ui.notify(
731
+ `Paste-time prewarm: ${config.prewarmPastedImages ? "on" : "off"}.\n` +
732
+ `Usage: /vision-handoff prewarm <on|off>`,
733
+ "info",
734
+ );
735
+ return;
736
+ }
737
+ if (value !== "on" && value !== "off") {
738
+ ctx.ui.notify("Usage: /vision-handoff prewarm <on|off>", "warning");
739
+ return;
740
+ }
741
+ const on = value === "on";
742
+ const note = on
743
+ ? editorInstalled
744
+ ? "Paste-time prewarm on โ€” pasted images are described the instant their path lands in the prompt (before submit)."
745
+ : "Paste-time prewarm on โ€” but another custom editor extension is active, so it's unavailable. Submit-time prewarm still works; disable the other editor extension (or /vision-handoff prewarm off) to silence this."
746
+ : "Paste-time prewarm off โ€” images are described at submit time (default).";
747
+ updateConfig(ctx, (c) => ({ ...c, prewarmPastedImages: on }), note);
748
+ }
749
+
632
750
  async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
633
751
  if (!ctx.hasUI) {
634
752
  ctx.ui.notify("/vision-handoff requires interactive mode.", "error");
@@ -692,6 +810,7 @@ function showStatus(ctx: ExtensionCommandContext): void {
692
810
  lines.push(`Auto handoff (non-vision models): ${config.autoHandoff ? "on" : "off"}`);
693
811
  lines.push(`Handoff targets (explicit): ${config.handoffModels.length ? config.handoffModels.join(", ") : "(none)"}`);
694
812
  lines.push(`Thinking: ${config.thinking ? `on (${config.thinkingLevel})` : "off"}`);
813
+ lines.push(`Paste-time prewarm: ${config.prewarmPastedImages ? `on${editorInstalled ? "" : " (inactive โ€” another custom editor is active)"}` : "off"}`);
695
814
  lines.push(`maxTokens: ${config.maxTokens ?? "unbounded"} ยท cacheMax: ${config.cacheMax} ยท maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
696
815
 
697
816
  const model = ctx.model;