Skip to content

Commit 864ea91

Browse files
committed
docs: design for image input (paste a screenshot, ask about it)
The interaction being copied — ⌘V a screenshot into the composer, no upload step — is the easy part. This documents the four things in our code that break quietly when the first image goes through, and the cost model that decides how bytes should be shaped before they leave the webview. The four, each verified against develop rather than recalled: 1. conversation carries content as a STRING (extension.js:1752). There is no shape an image can take. This is the structural change; everything else follows. 2. translate.js SILENTLY DROPS any block it doesn't recognise. On OpenAI-compatible providers an attached image would vanish between composer and wire, and the model would answer confidently about text it never saw — no error, no log line. A user would reasonably conclude the model hallucinates. Worth fixing on its own merits, before images, which is why it's slice I1. 3. `vision: true` is ALREADY in the catalog, per model, and nothing reads it. There's a supportsToolsForModel and no supportsVisionForModel. Half the gate exists. 4. estimateMsgTokens is JSON.stringify(m).length / 4 — sound for text, catastrophic for base64: a 1MB screenshot books ~333,000 phantom tokens, more than most context windows. The same estimate drives findCompactionCut, so pasting one screenshot would evict real conversation history. This is the bug that would have shipped as "long conversations forget things after I paste an image". ON THE NUMBERS. I checked the vision API rather than trusting my prior, and the prior was wrong: cost is not w×h/750, it is ⌈w/28⌉ × ⌈h/28⌉ visual tokens over 28px patches, with a per-tier cap (2576px/4784 tokens on 4.7+, 1568/1568 below). I reimplemented the resize rule and reproduced the documented figures exactly — 1092² → 1521, 1000² → 1296, 1920×1080 → 2691 — so the cost table in §1 is arithmetic, not estimate. That verification changed a decision. Token cost is ALREADY capped server-side, so client-side downscaling is not a defence against a token blowup — it is a deliberate fidelity-for-cost trade (4784 → 1792 on a 4K grab) and a defence against the wire. The doc says so rather than implying downscaling is load-bearing for cost safety. Also recorded: writing §D3 I ran a 1160×480 capture through `sips -Z 1568` and it GREW, 40KB → 89KB, because the tool scaled it up to meet the cap. Never upscale — the rule is min(1, cap/longEdge), and a factor of 1 means pass the original bytes through untouched, which also avoids stacking compression artifacts on screenshots of text. Seven slices, I1–I7, each independently shippable with bypass-verifiable exit criteria. Deferred with reasons: Files API upload (Anthropic-only, wins on repeat turns), PDFs, coordinates, client-side OCR.
1 parent 64c7073 commit 864ea91

1 file changed

Lines changed: 250 additions & 0 deletions

File tree

docs/IMAGES.md

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# Image input — paste a screenshot, get an answer about it
2+
3+
**Status:** design, not built. Seven slices, I1–I7.
4+
5+
The target is the interaction Cursor and the Claude Code console already have: take a screenshot, `⌘V` into the composer, ask "why does this look wrong". No dialog, no upload step, no file management.
6+
7+
The interaction is the easy part. This document is mostly about four things in our codebase that will quietly break when the first image goes through, and the cost model that decides how the bytes should be shaped before they leave the webview.
8+
9+
---
10+
11+
## 0. What is true today
12+
13+
Verified against `develop`, not recalled.
14+
15+
**The conversation carries strings, not blocks.** `handleSend` assembles context and text into one string:
16+
17+
```js
18+
const userContent = blocks.length ? (blocks.join('\n\n') + '\n\n' + text) : text;
19+
conversation.push({ role: 'user', content: userContent });
20+
```
21+
22+
`blocks` here are *text* fragments — the workspace map, file contents, pending context. There is no shape in which an image could be expressed. This is the structural change, and everything else follows from it.
23+
24+
**`translate.js` silently drops any block it does not recognise.** Both the flatten path and the user-message path enumerate known types and fall through:
25+
26+
```js
27+
if (b.type === 'tool_result') { … }
28+
else if (b.type === 'text') { trailingText += (b.text || ''); }
29+
// an image block reaches here and is discarded, without a trace
30+
```
31+
32+
This is the most dangerous thing in the list. On any OpenAI-compatible provider — which is most of them through the gateway — an attached image would **vanish between the composer and the wire**, and the model would answer confidently about text it never saw. No error, no warning, no log line. A user would reasonably conclude the model is hallucinating.
33+
34+
**The vision capability is already modelled, and nothing reads it.** `providers/catalog.js` carries `vision: true` per model and has done since the multi-provider work:
35+
36+
```js
37+
'claude-opus-4-8': { context: 200000, tools: true, vision: true, caching: true },
38+
'gpt-4o': { context: 128000, tools: true, vision: true },
39+
```
40+
41+
There is a `supportsToolsForModel(providerId, modelId)`. There is no `supportsVisionForModel`. Half the gate exists.
42+
43+
**The context meter measures bytes, not tokens.** `agentMemory.js`:
44+
45+
```js
46+
function estimateMsgTokens(msgs) {
47+
return Math.round(msgs.reduce((n, m) => n + JSON.stringify(m).length, 0) / 4);
48+
}
49+
```
50+
51+
Sound for text. For a base64 image it charges roughly **one third of the byte count as tokens** — a 1 MB screenshot books ~333,000 phantom tokens, which is larger than most context windows. `findCompactionCut` would fire on the first screenshot and evict real conversation history to make room for an image that actually costs ~4,800. This is not a rounding error; it is the meter reading the wrong quantity entirely.
52+
53+
**Nothing in the composer accepts an image.** No `paste`, `drop`, or `DataTransfer` handling in `media/chat.html`. Sessions persist message objects verbatim into append-only JSONL, which is re-read on resume.
54+
55+
---
56+
57+
## 1. The numbers that decide the design
58+
59+
From the vision documentation, checked rather than recalled — the figure I had in mind (`w × h / 750`) is stale and wrong.
60+
61+
**Claude sees 28×28-pixel patches.** An image costs:
62+
63+
```
64+
⌈width / 28⌉ × ⌈height / 28⌉ visual tokens
65+
```
66+
67+
**Each model has a resolution tier, and the server enforces it.**
68+
69+
| Tier | Models | Max long edge | Max visual tokens |
70+
|---|---|---|---|
71+
| High-resolution | Claude 4.7 and later | 2576 px | 4784 |
72+
| Standard | everything else | 1568 px | 1568 |
73+
74+
Images above either limit are **downscaled server-side, preserving aspect ratio**. I reimplemented the rule and reproduced the documented figures exactly (1092² → 1521 tokens, 1000² → 1296, 1920×1080 → 2691), so the arithmetic below is trustworthy:
75+
76+
| Source | Sent as (high-res tier) | Tokens | If we cap the long edge at 1568 | Tokens |
77+
|---|---|---|---|---|
78+
| 4K screenshot 3840×2160 | 2576×1449 | **4784** | 1568×882 | **1792** |
79+
| macOS retina window 3024×1964 | 2377×1544 | 4760 | 1568×1018 | 2072 |
80+
| 1080p screenshot 1920×1080 | unchanged | 2691 | 1568×882 | 1792 |
81+
| 12 MP phone photo 4032×3024 | 2193×1645 | 4661 | 1568×1176 | 2352 |
82+
83+
**The consequence that shapes everything: token cost is already capped by the server.** Sending a 12 MB PNG does not buy more than 4784 tokens of fidelity — it buys latency and bandwidth. So client-side downscaling is **not** a defence against a token blowup. It is a deliberate fidelity-for-cost trade, and a defence against the *wire*.
84+
85+
Other limits worth designing against:
86+
87+
- **Per image:** 10 MB base64 on the Claude API, 5 MB on Bedrock and Google Cloud.
88+
- **Per request:** 100 images for 200k-context models, 600 otherwise — but the 32 MB request cap is reached first.
89+
- **Above 20 images in one request**, a stricter per-image dimension limit applies; keep every image under 2000 px per side to stay safe.
90+
- **Max dimensions:** 8000×8000.
91+
- **Formats:** JPEG, PNG, GIF, WebP only. Animations unsupported — only the first frame is read.
92+
- **Images before text works best.** Placement matters; put the image first in the user turn.
93+
- **Base64 images are resent on every turn.** In a long conversation the same screenshot crosses the wire on every request.
94+
- **Compression artifacts hurt, especially on text**, and repeated compression passes compound. Relevant to us because our images are mostly screenshots of code and UI.
95+
96+
---
97+
98+
## 2. Decisions
99+
100+
### D1 — Widen `content` to blocks, but only when there is an image
101+
102+
`content` becomes `string | Block[]`. Text-only turns keep the string, unchanged.
103+
104+
The alternative — blocks everywhere — is cleaner in the abstract and worse here: it churns every call site that reads `m.content`, changes the on-disk session format for every historical entry, and invalidates prompt caching for conversations that never touch an image. Widening at the point of need costs one type check at the boundary and nothing else.
105+
106+
### D2 — Image first, text after
107+
108+
Documented model behaviour, free to honour. The image block leads the user turn; our existing text context blocks (workspace map, file contents) and the typed message follow.
109+
110+
### D3 — Normalize at the webview boundary: downscale only, single compression pass
111+
112+
Three rules, each earning its place:
113+
114+
**Never upscale.** Writing this document I ran a 1160×480 capture through `sips -Z 1568` and it *grew* from 40 KB to 89 KB — the tool scaled it up to meet the cap. Upscaling costs bytes and tokens and adds precisely zero information. Scale factor is `min(1, cap / longEdge)`, and a factor of 1 means pass through.
115+
116+
**Re-encode only if we resized.** If the source is already inside the cap, forward the original bytes untouched. Every re-encode of an already-lossy source stacks artifacts, and the documentation calls that out specifically for text legibility — which is the entire content of a code screenshot.
117+
118+
**Default cap: 1568 px on the long edge**, with `levelcode.ai.chat.imageMaxEdge` to raise it to 2576 for dense documents. Reasoning: it is a documented breakpoint rather than an invented one; it more than halves token cost against the high-res cap (1792 vs 4784 on a 4K grab); it stays under the 2000 px many-image threshold; and at 1568 px a typical logical UI screenshot is still supersampled, so text stays legible. Users doing computer-use or dense-document work can raise it.
119+
120+
Format policy: **PNG in, PNG out** — screenshots are flat-colour UI where PNG is both smaller and lossless. Fall back to WebP q0.9 only when a resized PNG exceeds a byte budget. Never JPEG a screenshot of text.
121+
122+
Do the work off the main thread — `createImageBitmap` + `OffscreenCanvas` — and revoke every object URL. A 4K decode on the UI thread is a visible stall in a chat window.
123+
124+
### D4 — Bytes on disk, content-addressed; messages carry a reference
125+
126+
The in-memory message and the session log carry:
127+
128+
```js
129+
{ type: 'image', ref: '<sha256>', media_type: 'image/png', w: 1568, h: 882, bytes: 214_003 }
130+
```
131+
132+
Bytes live at `media/<sha256>.<ext>` beside the session index. Base64 is materialized **only** when building the provider request, and never retained.
133+
134+
Three reasons. The session JSONL is append-only and fully re-read on resume — multi-megabyte base64 lines make it slow to parse and impossible to read. `postMessage` between webview and extension host would otherwise carry the same blob twice. And content addressing means the same screenshot pasted twice is one file, which is the common case when someone re-pastes after a failed send.
135+
136+
### D5 — Token accounting must know what an image costs
137+
138+
`estimateMsgTokens` is wrong in both directions: catastrophically over, if base64 lands in the message; quietly under, once refs replace it (a 70-character ref reads as ~18 tokens instead of ~1800).
139+
140+
It needs an explicit branch: for an image block, add `⌈w/28⌉ × ⌈h/28⌉`, clamped to the tier cap for the active model. The dimensions are recorded at normalize time, so this is arithmetic, not I/O.
141+
142+
Getting this wrong is not cosmetic — the same estimate drives `findCompactionCut`, so a wrong number silently evicts conversation history.
143+
144+
### D6 — The provider boundary fails loudly
145+
146+
`translate.js` learns the image block:
147+
148+
```js
149+
{ type: 'image_url', image_url: { url: `data:${media_type};base64,${data}` } }
150+
```
151+
152+
And — separately — the fall-through that currently discards unknown blocks becomes an explicit throw. A block type the translator does not understand is a bug in us, and the correct behaviour is a loud failure at the boundary, not a request that looks fine and is missing its subject. This is worth doing on its own merits even before images ship.
153+
154+
### D7 — Gate on the capability that already exists
155+
156+
Add `supportsVisionForModel(providerId, modelId)` alongside `supportsToolsForModel`, reading the `vision` flag already in the catalog. The attach affordance is disabled, with the reason named, when the selected model cannot see. Attempting to send an image to a text-only model refuses with a message that says which model and suggests one that can.
157+
158+
### D8 — Three ways in; paste is the one that matters
159+
160+
1. **Paste** (`⌘V` with an image on the clipboard) — the 90% case, and the whole interaction being copied.
161+
2. **Drag and drop** onto the transcript or composer.
162+
3. **The existing Add Files button**, which should accept an image file from the workspace rather than reading it as text.
163+
164+
An attached image shows as a thumbnail chip in the composer, removable before send, and renders as a bounded thumbnail in the transcript — never the base64, and never at native size.
165+
166+
### D9 — Multi-turn repetition is a known, deferred cost
167+
168+
Base64 rides on every subsequent request. Refs keep *our* history small but do not shrink the wire. The Files API (`{type:'image', source:{type:'file', file_id}}`, beta `files-api-2025-04-14`) fixes it properly by uploading once and referencing thereafter — but it is Anthropic-direct only, so it cannot be the primary path in a multi-provider client. Flagged as a follow-up, sized in §6.
169+
170+
---
171+
172+
## 3. The pipeline
173+
174+
```
175+
clipboard / drop / picker
176+
│ Blob
177+
178+
[webview] decode → downscale (only if over cap, never up) → encode (only if resized)
179+
│ { base64, media_type, w, h } one crossing, one copy
180+
181+
[host] sha256 → write media/<sha>.<ext> → { type:'image', ref, w, h, media_type }
182+
183+
├─► conversation[] (ref — small)
184+
├─► session JSONL (ref — small, readable, resumable)
185+
├─► estimateMsgTokens (⌈w/28⌉ × ⌈h/28⌉, tier-clamped)
186+
└─► transcript (thumbnail from a webview-safe URI)
187+
188+
▼ at request-build time only
189+
[provider] read file → base64 → Anthropic image block
190+
→ OpenAI image_url data: URI
191+
→ throw if the provider cannot carry it
192+
```
193+
194+
---
195+
196+
## 4. Slices
197+
198+
Each is independently shippable and independently revertible. Exit criteria are the guards, and every guard is bypass-verified — the fix is reverted and the test must fail.
199+
200+
**I1 — Fail loudly at the translator.** Turn the silent block drop into a throw; add the image → `image_url` mapping. No UI. Ships alone because the silent-drop bug predates images.
201+
*Exit:* a non-text block reaching `translate.js` throws with the block type named; an image block round-trips to `image_url`; the existing text and tool_result paths are unchanged.
202+
203+
**I2 — Vision gate.** `supportsVisionForModel`, exercised nowhere yet.
204+
*Exit:* returns false for a model with `vision: false`, true for `vision: true`, and follows the same exact → basename → family → default resolution chain as `supportsToolsForModel`.
205+
206+
**I3 — The normalizer, pure and tested.** `normalizeImage(bitmapLike, cap)``{w, h, scaled, reencoded}`. Pure geometry, no canvas, unit-testable.
207+
*Exit:* never returns dimensions larger than the source; returns `scaled:false, reencoded:false` when already under the cap; preserves aspect ratio within a pixel; the tier-clamped token estimate matches the documented table for all six rows in §1.
208+
209+
**I4 — Store and account.** Content-addressed write, the `{type:'image', ref}` shape, `estimateMsgTokens` learning images.
210+
*Exit:* the same bytes stored twice produce one file; a session containing an image resumes; the meter charges the computed visual tokens and **not** the JSON length — verified by asserting a 1 MB image does not book six figures of tokens.
211+
212+
**I5 — Paste.** Clipboard → normalize → chip → send. The end-to-end path on one input method.
213+
*Exit:* pasting an image produces a chip and no base64 in `conversation`; the chip is removable; a paste of text is unaffected; the request carries an image block before the text block.
214+
215+
**I6 — Drop, picker, and the transcript thumbnail.** The remaining two inputs, and rendering.
216+
*Exit:* dropping an image file and choosing one via Add Files both reach the same normalizer; the transcript renders a bounded thumbnail; object URLs are revoked on teardown.
217+
218+
**I7 — The refusals.** Model without vision, image too large, unsupported format, too many images.
219+
*Exit:* each refuses before the request is built, names the actual constraint, and leaves the composer contents intact so nothing typed is lost.
220+
221+
---
222+
223+
## 5. Budget
224+
225+
Per screenshot, at the 1568 default, against doing nothing:
226+
227+
| | Native 4K | Normalized | Change |
228+
|---|---|---|---|
229+
| Visual tokens | 4784 (server-capped) | 1792 | **2.7× fewer** |
230+
| Pixels on the wire | 8.3 MP | 1.4 MP | **6× fewer** |
231+
| Base64 inflation | ×4/3 of encoded bytes | ×4/3 | unchanged — it is the pixel count that moves |
232+
| Bytes in the session log | multi-MB per turn | ~70 bytes | ref, not blob |
233+
| Token-meter error | ~333,000 phantom tokens per MB | 0 | the compaction bug |
234+
235+
The last row is the one that would have shipped as a mystery bug report: *"long conversations forget things after I paste a screenshot."*
236+
237+
---
238+
239+
## 6. Not in scope
240+
241+
- **Files API upload** (D9). Anthropic-direct only; worth doing once image use is real, and worth measuring first — the win is on repeat turns, not the first one.
242+
- **Image *output*.** Claude does not generate images. Nothing to build.
243+
- **PDF and document blocks.** Adjacent, different limits, different block type.
244+
- **Coordinates and bounding boxes.** Only interesting if the agent gains a computer-use tool; the resize rule interacts with coordinate mapping and would need its own design.
245+
- **OCR or client-side preprocessing.** The model reads text in images; adding our own pass adds failure modes.
246+
247+
## 7. Open
248+
249+
- **Cap default 1568 or 2576.** Named a decision above rather than left open, but it should be re-measured against real code screenshots before I5 ships — if 11px editor text is unreadable at 1568, the default moves to 2576 and the setting inverts.
250+
- **Whether the workspace-file path should route images through this pipeline at all**, or attach by path and let the tools read them. Attaching by path costs nothing until read; pasting has no path.

0 commit comments

Comments
 (0)