diff --git a/docs/CORE-PATCHES.md b/docs/CORE-PATCHES.md index 32c0794..1548676 100644 --- a/docs/CORE-PATCHES.md +++ b/docs/CORE-PATCHES.md @@ -26,7 +26,7 @@ So a full rebuild from nothing is: `bootstrap.sh` (clone → brand → extension To re-create the core patch after changing core files in `vscode/`: ```bash -# STRUCTURAL patch only (14 files). Display-string rebrands are NOT here — they live in scripts/de-brand.mjs. +# STRUCTURAL patch only (15 files). Display-string rebrands are NOT here — they live in scripts/de-brand.mjs. git -C vscode diff HEAD -- \ src/vs/workbench/contrib/files/browser/files.contribution.ts \ build/lib/extensions.ts build/lib/copilot.ts \ @@ -41,6 +41,7 @@ git -C vscode diff HEAD -- \ src/vs/base/common/product.ts \ src/vs/platform/dialogs/electron-browser/dialog.ts \ src/vs/workbench/contrib/update/browser/updateTooltip.ts \ + src/vs/workbench/browser/parts/editor/editorDropTarget.ts \ > patches/levelcode-core.patch # NOTE 1: use `diff HEAD` (not plain `diff`) — bootstrap's `git apply` may leave these STAGED, # and plain `git diff` shows only UNSTAGED changes, silently dropping the staged patches. @@ -64,6 +65,34 @@ grep -rn "\[LevelCode\]" vscode/src vscode/build ## Patches (structural / behavioural only) +### `editorDropTarget.ts` — an image dropped on the chat is an attachment, not a file to open + +**Why it has to be here.** A webview iframe is never offered an OS file drop: the workbench takes the +drop first and opens the file in a tab. Nothing inside `extensions/levelcode-ai` can recover it — the +panel's own `drop` handler never fires, and the `text/uri-list` fallback has no event to fall back +from. This is the one part of paste-a-screenshot that cannot be an extension change. + +**What it does.** In `DropOverlay.handleDrop`, immediately before the URI-transfer branch hands off to +`ResourcesDropHandler`, `tryLevelCodeChatImageDrop` forwards the dropped paths to the extension via +`levelcode.ai.attachImagePaths` and consumes the drop. + +**Kept narrow on purpose** — every condition is a reason not to change behaviour someone relies on: + +- only when the chat webview is the **active editor of the group being dropped on**, so a drop on any + other tab still opens the file; +- only when **no split** is requested, so dragging to an edge still splits the group; +- only when **every** dropped file is an image, so a mixed drop behaves as it always did; +- only when the paths resolve — `getPathForFile` is native-only and returns undefined on web; +- and if the command throws (extension not activated), it **falls through** to the normal handler, + because a dropped image doing nothing at all is worse than one that opens. + +The paths travel by command rather than a new IPC channel, so the diff stays a routing decision and +nothing more — which is what keeps it cheap to re-apply on a rebase. + +**Regenerating:** this file was appended per NOTE 3, not swept in by a wholesale regen. A wholesale +regen on a de-branded checkout pulls ~78 lines of link-strips into `files.contribution.ts`; I did that +once while adding this entry and had to back it out. + These can't be a content swap — behaviour, build logic, unregistrations. Kept small on purpose so they survive Code-OSS bumps. Each is tagged `[LevelCode]`. **The build is strict** (`noUnusedLocals` + `allowUnreachableCode: false`), so these avoid dead early-`return`s (unreachable-code error), commented-out diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index fb2a462..a763cd1 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -633,6 +633,20 @@ async function approveMcpLaunch(ctx, server, dbg) { return true; } +/** + * What we last TOLD the user about a run's context (rules / memory / MCP). + * + * The context itself is rebuilt every run — that is deliberate, a run's servers are whatever is + * configured and reachable right now. Re-ANNOUNCING it every turn is different, and it was noise: + * three identical rows at the top of every single answer, saying the same thing they said last time. + * + * A signature, not a boolean, because the announcement has to come back the moment anything moves — + * a server dropping out, a rules file appearing, memory arriving for the first time. Silence is only + * correct while the picture is unchanged. + */ +let lastContextSig = ''; +function resetContextAnnounce() { lastContextSig = ''; } + async function setupMcp(ctx, wsFolders, dbg) { const empty = { tools: [], routes: null }; const cfg = ctx.mcp || {}; @@ -687,7 +701,9 @@ async function setupMcp(ctx, wsFolders, dbg) { const perServer = toolCountsByServer(built.routes); const summary = handles.map((h) => h.name + ' (' + (perServer.get(h.name) || 0) + ')').join(', '); dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed }); - ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' }); + // Handed back rather than posted: runAgent decides whether the user needs to hear it again. + // Failures below still post immediately — a server that broke is news every time. + built.announce = { type: 'agentTool', icon: 'sparkle', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' }; return built; } catch (e) { dbg('mcp.failed', { error: (e && e.message) || String(e) }); @@ -739,21 +755,36 @@ async function runAgent(ctx) { const systemTokensEst = Math.round(system.length / 4); const dbg = ctx.dbg || (() => {}); + // The run's context, COLLECTED rather than posted. Whether the user needs to see it again is a + // question about the whole picture, and the MCP part of that picture is not known until setupMcp + // has run — so nothing is announced until all three are in hand. + const contextChips = []; if (rules.sources.length) { dbg('projectRules.loaded', { sources: rules.sources }); - // Quiet timeline chip at the top of the run so the user can see their repo rules are in effect - // (mirrors the skill chip). Reuses the agentTool → addAgentLine rendering — no webview change. - ctx.post({ type: 'agentTool', icon: 'file', text: '📋 project rules · ' + rules.sources.join(', ') }); + contextChips.push({ type: 'agentTool', icon: 'file', text: '📋 project rules · ' + rules.sources.join(', ') }); } if (ctx.projectMemory) { dbg('projectMemory.loaded', { chars: ctx.projectMemory.length }); - ctx.post({ type: 'agentTool', icon: 'history', text: '🧠 project memory' }); + contextChips.push({ type: 'agentTool', icon: 'history', text: '🧠 project memory' }); } // MCP (docs/MCP.md S3): the tool list becomes PER-RUN. It was a module constant only because it was // the same every time; a run's servers are whatever is configured and reachable right now. Same shape // as `system`/`systemTokensEst` two lines up — built once per run, then used for every turn. const mcp = await setupMcp(ctx, wsFolders, dbg); + if (mcp.announce) { contextChips.push(mcp.announce); } + + // Say it only when it CHANGED. The context is rebuilt every run by design; repeating it at the top + // of every answer is not the same thing, and three identical rows before each reply is noise the + // reference transcript does not have. A signature rather than a flag, so the announcement returns + // the moment a server drops, a rules file appears, or memory shows up for the first time. + const sig = contextChips.map((c) => c.text).join('|'); + if (sig && sig !== lastContextSig) { + lastContextSig = sig; + for (const chip of contextChips) { ctx.post(chip); } + } else if (!sig) { + lastContextSig = ''; // nothing to say now; say it again when there is + } ctx.mcpRoutes = mcp.routes; // runTool's router reads this // Rootless runs get the portable subset; MCP tools are unaffected either way. const builtins = root ? TOOLS : PORTABLE_TOOLS; @@ -1030,4 +1061,4 @@ async function runAgent(ctx) { } } -module.exports = { runAgent, makeDiff, resolveWorkspacePath }; +module.exports = { resetContextAnnounce, runAgent, makeDiff, resolveWorkspacePath }; diff --git a/extensions/levelcode-ai/agentMemory.js b/extensions/levelcode-ai/agentMemory.js index 5f9f2cf..a4c9a3e 100644 --- a/extensions/levelcode-ai/agentMemory.js +++ b/extensions/levelcode-ai/agentMemory.js @@ -6,6 +6,8 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; + +const { imageBlockTokens } = require('./imageCost'); /** A "goal boundary": a user message with plain STRING content (a fresh user turn, never a tool_result). * It is the only splice point that cannot orphan a tool_use/tool_result pair — tool results always sit * in the message immediately after their tool_use, so any pair is wholly on one side of such a cut. */ @@ -34,10 +36,32 @@ function findCompactionCut(msgs, keepRecent) { return cut; } -/** Rough token estimate for a message list — the house chars/4 heuristic, used only for the UI meter. */ -function estimateMsgTokens(msgs) { +/** + * Rough token estimate for a message list — the house chars/4 heuristic, used only for the UI meter. + * + * Images are counted by their real visual cost, not by their JSON. chars/4 is sound for text and + * wrong for an image in whichever shape it takes: inline base64 books about a third of its byte + * count (a 1MB screenshot reads as ~333,000 tokens, more than most context windows, for something + * that really costs ~4,800), and a stored ref swings the other way — 64 hex characters read as ~18 + * tokens for the same ~4,800. Both would make the meter lie about how much room is left. + * + * `modelId` picks the resolution tier; omitting it costs the standard tier, which over-counts + * rather than under-counts. See imageCost.js. + */ +function estimateMsgTokens(msgs, modelId) { if (!Array.isArray(msgs)) { return 0; } - return Math.round(msgs.reduce((n, m) => n + JSON.stringify(m).length, 0) / 4); + let chars = 0; + let imageTokens = 0; + for (const m of msgs) { + if (!m) { continue; } + if (!Array.isArray(m.content)) { chars += JSON.stringify(m).length; continue; } + chars += 24; // role + envelope, roughly what the object costs around its blocks + for (const b of m.content) { + if (b && b.type === 'image') { imageTokens += imageBlockTokens(b, modelId); } + else { chars += JSON.stringify(b).length; } + } + } + return Math.round(chars / 4) + imageTokens; } module.exports = { isGoalBoundary, findCompactionCut, estimateMsgTokens }; diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 4b64c49..91ddd15 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -20,8 +20,10 @@ const { resolveGateway } = require('./providers/gateway'); const { registerAiEdit } = require('./aiEdit'); const { registerLmProvider } = require('./lmProvider'); const { registerInlineComplete } = require('./inlineComplete'); -const { runAgent } = require('./agent'); +const { runAgent, resetContextAnnounce } = require('./agent'); const { findCompactionCut, estimateMsgTokens } = require('./agentMemory'); +const imageStore = require('./imageStore'); +const { supportsVisionForModel } = require('./providers/catalog'); const sessionStore = require('./sessionStore'); const sessionEvents = require('./sessionEvents'); const sessionMemory = require('./sessionMemory'); @@ -146,6 +148,19 @@ function contextLimitFor(providerId, model) { } /** The active model's context window (tokens) — drives the chat context-usage meter. */ +/** + * The model the meter should cost against — the same resolution currentContextLimit uses. + * + * estimateMsgTokens needs it to pick an image's resolution tier: the high-res tier is 4,784 visual + * tokens against the standard tier's 1,568, so calling it without a model silently costs every + * screenshot at a THIRD of what a Claude 4.7+ model is actually charged. + */ +function meterModel() { + const cfg = aiConfig(); + if (providerMode() === 'gateway' && cloudSignedIn) { return capsModel(gatewayModel()); } + return activeModel(cfg, currentProviderId()); +} + function currentContextLimit() { const cfg = aiConfig(); if (providerMode() === 'gateway' && cloudSignedIn) { @@ -1107,7 +1122,17 @@ function sealLiveSession(why) { if (!m) { return; } const sealedId = m.liveId(); m.seal('done'); - if (sealedId) { enrichMemoryAsync(sealedId); } // outcome + fact promotion, off the critical path + if (sealedId) { enrichMemoryAsync(sealedId); } + // Sealing is the natural moment to take out the rubbish: rare, already off the hot path, and + // the point at which a conversation's refs have just been written. Nothing else deletes media + // — sessions are append-only and trash() only marks a lifecycle — so without this the folder + // grows for the life of the project. + setTimeout(() => { + try { + const swept = m.sweepMedia(); + if (swept.removed) { dbg('media.swept', { removed: swept.removed, kb: Math.round(swept.bytes / 1024) }); } + } catch (e) { dbg('media.sweep.error', { msg: String((e && e.message) || e) }); } + }, 0); // outcome + fact promotion, off the critical path dbg('sessions.sealed', { why, id: sealedId }); } catch (e) { dbg('sessions.seal.error', { why, msg: String((e && e.message) || e) }); @@ -1135,6 +1160,9 @@ function resetConversationState() { // teardown loses a race it does not know it is in — see handleSend/agentFlow, both of which mutate // this state from a catch/finally that runs long after abort() returns. conversationEpoch++; + // A new conversation starts clean, so the run's context (rules / memory / MCP) is news again. + // The per-turn suppression is about repetition WITHIN a conversation, not across them. + try { resetContextAnnounce(); } catch (e) { /* older agent module */ } clearApprovals(); clearQuestions(); conversation = []; @@ -1539,7 +1567,7 @@ async function compactAgentMemory() { if (abort) { return { ok: false, reason: 'running' }; } const msgs = agentMessages; const KEEP_RECENT = 8; - const beforeMsgTokens = estimateMsgTokens(msgs); + const beforeMsgTokens = estimateMsgTokens(msgs, meterModel()); if (msgs.length <= KEEP_RECENT + 2) { return { ok: false, reason: 'small' }; } const cut = findCompactionCut(msgs, KEEP_RECENT); if (cut < 0) { return { ok: false, reason: 'noboundary' }; } @@ -1586,13 +1614,13 @@ async function compactAgentMemory() { // transcript for them (it guards on indexOf), so keeping them would offer a half-working rollback. for (let i = checkpoints.length - 1; i >= 0; i--) { if (msgs.indexOf(checkpoints[i].goalMsg) < 0) { checkpoints.splice(i, 1); } } - const afterMsgTokens = estimateMsgTokens(msgs); + const afterMsgTokens = estimateMsgTokens(msgs, meterModel()); dbg('compact.done', { cut, beforeMsgTokens, afterMsgTokens, msgs: msgs.length }); return { ok: true, beforeMsgTokens, afterMsgTokens }; } let lastAgentGoal = null; // remembered so the response bar's Retry can re-run it -async function agentFlow(text) { +async function agentFlow(text, imageBlocks) { if (text && text.trim()) { lastAgentGoal = text; } const cfg = aiConfig(); const providerId = currentProviderId(); @@ -1612,7 +1640,11 @@ async function agentFlow(text) { abort = new AbortController(); repairAgentMemory(); // Open a workspace checkpoint for this turn (before the goal is pushed) so the user can roll back here. - const goalMsg = { role: 'user', content: text }; + // Agent mode is the DEFAULT, so this is the path most pasted screenshots take. Blocks only when + // there IS an image — a text-only goal stays a plain string so cached prefixes keep their bytes. + const goalMsg = (imageBlocks && imageBlocks.length) + ? { role: 'user', content: text ? [...labelImages(imageBlocks), { type: 'text', text }] : labelImages(imageBlocks) } + : { role: 'user', content: text }; currentCheckpoint = { turnId: ++checkpointSeq, label: (text || '').slice(0, 60), ts: Date.now(), goalMsg: goalMsg, files: new Map() }; checkpoints.push(currentCheckpoint); post({ type: 'checkpointOpened', turnId: currentCheckpoint.turnId }); @@ -1642,7 +1674,7 @@ async function agentFlow(text) { dbg('verify.config', { enabled: verifyCfg.enabled, hasCommand: !!verifyCfg.command, maxRounds: verifyCfg.maxRounds, includeWarnings: verifyCfg.includeWarnings }); try { await runAgent({ - messages: agentMessages, // persists across runs → the agent remembers the session + messages: withImages(agentMessages), // persists across runs → the agent remembers the session providerId: req.providerId, // Anthropic native, or an OpenAI-shaped provider via translation (P2) baseURL: req.baseURL, // for the custom / Ollama endpoints label: req.label, // route name for error attribution — "LevelCode Cloud" on the gateway, @@ -1724,10 +1756,189 @@ async function agentFlow(text) { } } -async function handleSend(text) { - if (!text || !text.trim()) { return; } +/** + * Where attached images live. + * + * Beside the project's sessions when there is a workspace, so they are cleaned up with it. Without + * one they fall back to a shared bucket — images need a place on DISK, not a session, and v1.1.0 + * deliberately made the agent answer with no folder open. Refusing to accept a screenshot in that + * state would re-introduce exactly the limitation that release removed. + */ +function imageRoot() { + const m = sessionsManager(); + if (m && m.mediaRoot) { return m.mediaRoot(); } + try { return { root: sessionsRoot(), slug: '_no-workspace' }; } + catch (e) { dbg('image.root.failed', { msg: String((e && e.message) || e) }); return null; } +} + +/** + * Read image files from disk and hand their bytes to the webview to normalize. + * + * Two callers, one path. The picker (reliable everywhere) and a Finder drop that arrives as a + * uri-list rather than as File objects — VS Code's workbench intercepts OS file drops before a + * webview iframe sees them, so `dataTransfer.files` is often empty while the PATH is still there. + * Reading host-side covers both, and normalization still happens in the webview because that is + * the only place with a canvas. + */ +async function attachImagePaths(paths) { + const files = []; + for (const fsPath of (Array.isArray(paths) ? paths : []).slice(0, maxImagesPerMessage())) { + try { + const ext = String(path.extname(fsPath) || '').toLowerCase(); + const mt = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp' }[ext]; + if (!mt) { vscode.window.showWarningMessage(path.basename(fsPath) + ' is not an image LevelCode can read.'); continue; } + const buf = await fs.promises.readFile(fsPath); + // Guard before the bytes cross into the webview: a 200MB file would otherwise be + // base64-ed onto the message bus before anything got a chance to refuse it. + if (buf.length > 25 * 1024 * 1024) { + vscode.window.showWarningMessage(path.basename(fsPath) + ' is too large to attach.'); + continue; + } + files.push({ base64: buf.toString('base64'), media_type: mt, name: path.basename(fsPath) }); + } catch (e) { + dbg('image.read.failed', { msg: String((e && e.message) || e) }); + vscode.window.showWarningMessage('Could not read ' + path.basename(fsPath)); + } + } + if (files.length) { post({ type: 'attachImages', files }); } +} + +const IMAGE_EXTS = /\.(png|jpe?g|gif|webp)$/i; + +/** Images allowed on one message. Clamped at the boundary: the settings-editor minimum/maximum only + * guides the UI, and a hand-edited settings.json arrives unchecked. */ +function maxImagesPerMessage() { + const n = Number(aiConfig().get('chat.maxImagesPerMessage', 5)); + if (!Number.isFinite(n) || n < 1) { return 1; } + return Math.min(Math.floor(n), 20); +} + +/** Every image currently open as a tab, newest group first. Deduped by path. */ +function openImageTabs() { + const seen = new Set(); + const out = []; + try { + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + const uri = tab && tab.input && tab.input.uri; + if (!uri || uri.scheme !== 'file' || !IMAGE_EXTS.test(uri.fsPath)) { continue; } + if (seen.has(uri.fsPath)) { continue; } + seen.add(uri.fsPath); + out.push({ fsPath: uri.fsPath, active: !!(tab && tab.isActive) }); + } + } + } catch (e) { dbg('image.tabs.failed', { msg: String((e && e.message) || e) }); } + return out; +} + +/** + * Attach images — from an open tab, or from disk. + * + * The open-tab list exists because of what VS Code does with a Finder drag: the workbench takes + * the drop and OPENS the file before a webview iframe sees any event at all, so a drop handler + * inside the panel can never fire. The image the user meant to attach is therefore sitting right + * there in a tab, and offering it is the shortest path from what actually happened to what they + * wanted. Active tab first, since that is the one they just dropped. + */ +async function pickImages() { + const tabs = openImageTabs(); + if (tabs.length) { + const BROWSE = 'Browse…'; + const items = tabs + .sort((a, b) => (b.active ? 1 : 0) - (a.active ? 1 : 0)) + .map((t) => ({ + label: path.basename(t.fsPath), + description: t.active ? 'open · active tab' : 'open in a tab', + detail: t.fsPath, + fsPath: t.fsPath + })); + items.push({ label: BROWSE, description: 'choose a file from disk' }); + const pick = await vscode.window.showQuickPick(items, { + title: 'Attach an image', + placeHolder: 'Dropping a file onto the editor opens it in a tab — attach it from here' + }); + if (!pick) { return; } + if (pick.fsPath) { await attachImagePaths([pick.fsPath]); return; } + } + const picked = await vscode.window.showOpenDialog({ + canSelectMany: true, openLabel: 'Attach', + filters: { Images: ['png', 'jpg', 'jpeg', 'gif', 'webp'] } + }); + if (picked && picked.length) { await attachImagePaths(picked.map((u) => u.fsPath)); } +} + +/** + * Store what the webview normalized, and return the blocks that will ride the conversation. + * + * Bytes land in the session's own media/ directory and the message keeps only a ref. Refused + * images are reported and skipped rather than failing the whole send — someone who pasted three + * screenshots and one unreadable file should still get their question answered. + */ +function storeImages(images) { + const out = []; + if (!Array.isArray(images) || !images.length) { return out; } + const paths = imageRoot(); + if (!paths) { vscode.window.showWarningMessage('Nowhere to store the image — LevelCode has no storage directory.'); return out; } + for (const im of images) { + try { + const { ref, bytes } = imageStore.put(paths.root, paths.slug, im.base64, im.media_type); + out.push({ type: 'image', ref, w: Number(im.w) || 0, h: Number(im.h) || 0, bytes }); + } catch (e) { + const msg = String((e && e.message) || e).replace(/^imageStore: /, ''); + vscode.window.showWarningMessage('Could not attach an image: ' + msg); + dbg('image.store.failed', { msg }); + } + } + return out; +} + +/** + * Introduce each image with a short label when there is more than one. + * + * Straight from the vision guidance: with several images, precede each with "Image 1:", "Image 2:" + * so the conversation can refer to them by name — in the question being asked, and in every + * follow-up turn afterwards. Without it, "the second screenshot" has nothing to bind to. + * + * Only when there are several. A single image needs no name, and labelling it would put a pointless + * text block ahead of every screenshot anyone pastes. + */ +function labelImages(blocks) { + if (!Array.isArray(blocks) || blocks.length < 2) { return blocks; } + const out = []; + blocks.forEach((b, i) => { + out.push({ type: 'text', text: 'Image ' + (i + 1) + ':' }); + out.push(b); + }); + return out; +} + +/** + * A copy of `msgs` with every stored image turned into a real wire block. + * + * A COPY, deliberately. `agentMessages` persists across runs and is what recordTurn writes to the + * session log — materializing in place would put megabytes of base64 into both. + */ +function withImages(msgs) { + if (!Array.isArray(msgs)) { return msgs; } + const paths = imageRoot(); + if (!paths) { return msgs; } + let touched = false; + const out = msgs.map((msg) => { + if (!msg || !Array.isArray(msg.content)) { return msg; } + if (!msg.content.some((b) => b && b.type === 'image' && b.ref)) { return msg; } + touched = true; + return { ...msg, content: msg.content.map((b) => imageStore.materialize(paths.root, paths.slug, b)) }; + }); + return touched ? out : msgs; +} + +async function handleSend(text, images) { + const imageBlocks = storeImages(images); + if ((!text || !text.trim()) && !imageBlocks.length) { return; } + text = text || ''; if (ctx) { ctx.globalState.update('levelcode.ai.hasSentMessage', true); } // user engaged → stop auto-revealing the panel on launch - if (agentMode) { await agentFlow(text); return; } + if (agentMode) { await agentFlow(text, imageBlocks); return; } const cfg = aiConfig(); const providerId = currentProviderId(); dbg('chat.send', { provider: providerId, model: activeModel(cfg, providerId), chars: text.length, history: conversation.length }); @@ -1750,7 +1961,14 @@ async function handleSend(text) { if (pendingContext) { blocks.push(pendingContext); } const userContent = blocks.length ? (blocks.join('\n\n') + '\n\n' + text) : text; - conversation.push({ role: 'user', content: userContent }); + // Blocks only when there is an image; a text-only turn stays a plain string so every cached + // prefix keeps the bytes it already had. Images lead — the model reads them best before the + // text that asks about them. + // An empty text block is a 400 from Anthropic ("text content blocks must be non-empty"), and an + // image sent with no words produces exactly that. Include the text block only when there is text. + conversation.push(imageBlocks.length + ? { role: 'user', content: userContent ? [...labelImages(imageBlocks), { type: 'text', text: userContent }] : labelImages(imageBlocks) } + : { role: 'user', content: userContent }); post({ type: 'userMessage', text }); if (auto.names.length) { post({ type: 'autoContext', names: auto.names }); } pendingContext = null; @@ -1772,7 +1990,7 @@ async function handleSend(text) { const doStream = (r) => providers.streamChat({ providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label, model: r.model, maxTokens: r.maxTokens, system: SYSTEM_PROMPT, - messages: conversation, signal: abort.signal, onDelta + messages: withImages(conversation), signal: abort.signal, onDelta }); try { await doStream(req); @@ -2240,14 +2458,16 @@ function sendConfigToWebview() { type: 'config', provider: 'gateway', proseSize, proseWidth, model: gatewayModelLabel(model), modelId: model, providerLabel: 'LevelCode Cloud', contextLimit: contextLimitFor('openai', capsModel(model)), gateway: true, plan: cloudPlanName() || 'Free', paid: isPaidCloudPlan(cloudPlanName()), - groupActivity: groupActivity + groupActivity: groupActivity, canSeeImages: supportsVisionForModel('openai', capsModel(model)), maxImages: maxImagesPerMessage() }); return; } const providerId = currentProviderId(); const p = providers.getProvider(providerId) || providers.getProvider('claude'); // Carry the model's context window so the footer meter updates the moment the model changes. - post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity }); + // canSeeImages travels with the model so the composer can refuse an attachment BEFORE anything is + // typed and lost, rather than after a send that the provider would reject. + post({ type: 'config', provider: providerId, proseSize, proseWidth, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity, canSeeImages: supportsVisionForModel(providerId, activeModel(cfg, providerId)), maxImages: maxImagesPerMessage() }); } /** @@ -2280,7 +2500,12 @@ class ChatViewProvider { case 'ready': cloudSignedIn = !!(ctx && await ctx.secrets.get(ACCOUNT_TOKEN_KEY)); autopilot = aiConfig().get('agent.autopilot', false); sendConfigToWebview(); postActiveFile(); postContextFiles(); post({ type: 'mode', agent: agentMode }); post({ type: 'autopilot', on: autopilot }); postAccount(); buildFileIndex(); post({ type: 'contextUsage', input: 0, limit: currentContextLimit() }); if (review) { review.resync(); } postMemoryDigest(); if (pendingTranscriptReplay) { const t = pendingTranscriptReplay; pendingTranscriptReplay = ''; replayLiveTranscript(t); } break; case 'setMode': agentMode = !!msg.agent; post({ type: 'mode', agent: agentMode }); break; case 'setAutopilot': autopilot = !!msg.on; aiConfig().update('agent.autopilot', autopilot, vscode.ConfigurationTarget.Global); dbg('autopilot.set', { on: autopilot }); post({ type: 'autopilot', on: autopilot }); break; - case 'send': await handleSend(msg.text); break; + case 'send': await handleSend(msg.text, msg.images); break; + // One surface for "that could not be attached" — VS Code's own, not a second one + // invented inside the transcript. + case 'notice': if (msg.text) { vscode.window.showWarningMessage(String(msg.text)); } break; + case 'pickImages': await pickImages(); break; + case 'attachImagePaths': await attachImagePaths(msg.paths); break; case 'stop': dbg('stop.clicked', { running: commandStops.size }); for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* gone */ } } if (abort) { abort.abort(); } clearApprovals(); clearQuestions(); break; case 'stopCommand': { dbg('stopCommand', { id: msg.id }); const s = commandStops.get(msg.id); if (s) { try { s(); } catch (e) { /* gone */ } } break; } case 'approvalResponse': { @@ -2479,6 +2704,9 @@ function webviewCsp() { const nonce = String(Math.random()).slice(2) + String(Date.now()); return { nonce, csp: [ "default-src 'none'", + // data: only — attached screenshots are rendered from their own bytes. Deliberately NOT + // https:, so the panel still cannot reach out to the network for an image. + "img-src data:", "style-src 'unsafe-inline'", "script-src 'nonce-" + nonce + "'" ].join('; ') }; @@ -2777,6 +3005,22 @@ function activate(context) { // Both land on the same panel, each on its own tab, so a button says exactly where it goes. vscode.commands.registerCommand('levelcode.ai.sessions', () => revealSessions('history')), vscode.commands.registerCommand('levelcode.ai.memory', () => revealSessions('memory')), + // Reachable from an image tab's title bar: dropping a file onto the workbench opens it there, + // so that is where someone already is when they realise the drop did not attach it. + // Called by the CORE patch in editorDropTarget.ts when an image is dropped onto the chat + // editor. Not in package.json's `commands` on purpose — it takes an argument and is not + // something to run from the palette. + vscode.commands.registerCommand('levelcode.ai.attachImagePaths', async (paths) => { + await focusChatView('drop'); + await attachImagePaths(Array.isArray(paths) ? paths : []); + }), + vscode.commands.registerCommand('levelcode.ai.attachImage', async (uri) => { + const fsPath = (uri && uri.fsPath) + || (vscode.window.activeTextEditor && vscode.window.activeTextEditor.document.uri.fsPath); + await focusChatView('attachImage'); + if (fsPath && IMAGE_EXTS.test(fsPath)) { await attachImagePaths([fsPath]); return; } + await pickImages(); + }), vscode.window.onDidChangeActiveTextEditor(() => postActiveFile()), // ⇧⌘I. Opens the chat where the chat lives — the editor tab. This pointed at the contributed // view, which is why the shortcut kept pulling a panel out on the right after the conversation diff --git a/extensions/levelcode-ai/imageCost.js b/extensions/levelcode-ai/imageCost.js new file mode 100644 index 0000000..e8faf70 --- /dev/null +++ b/extensions/levelcode-ai/imageCost.js @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Image geometry and cost — the arithmetic behind attaching a screenshot. + * + * Pure: no canvas, no fs, no vscode. The webview does the actual pixel work; this decides what + * the pixel work should aim for, and tells the context meter what the result costs. + * + * THE NUMBERS ARE NOT ESTIMATES. Claude sees images as 28x28 patches, so an image costs + * ceil(w/28) * ceil(h/28) visual tokens, and each model tier caps both the long edge and the + * token count, downscaling past either. This module reproduces every worked example in the + * vision documentation: 1092^2 -> 1521, 1000^2 -> 1296, 1920x1080 -> 2691 (high-res) / 1456x819 + * at 1560 (standard), 3840x2160 -> 2576x1449 at 4784. test/imageCost.test.js pins all of them. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +/** One visual token per 28x28 patch, ceiling on each axis independently. */ +const PATCH = 28; + +/** + * Per-tier limits. High-res is Claude 4.7 and later; everything else is standard. + * Both are enforced server-side — an image over either limit is downscaled before the model sees + * it, which is why sending more pixels than this buys latency rather than fidelity. + */ +const TIERS = { + high: { edge: 2576, tokens: 4784 }, + standard: { edge: 1568, tokens: 1568 } +}; + +/** Visual tokens for an image at these dimensions. */ +function visualTokens(w, h) { + if (!(w > 0) || !(h > 0)) { return 0; } + return Math.ceil(w / PATCH) * Math.ceil(h / PATCH); +} + +/** Which tier a model id lands in. Unknown -> standard, so we never UNDER-count a cost. */ +function tierFor(modelId) { + const id = String(modelId || '').toLowerCase(); + // Claude 4.7+ (including the 5 line) is the high-resolution tier. + if (/claude-(opus|sonnet|fable|mythos)-5/.test(id)) { return 'high'; } + if (/claude-opus-4-(7|8|9)/.test(id)) { return 'high'; } + return 'standard'; +} + +/** + * What the server will actually process, given a source size and a tier. + * + * The rule is the largest scale (never above 1) whose patch grid fits the tier's token cap, with + * the long edge bounded too. Binary search rather than stepping the scale down: a step-down loop + * lands a few pixels short and misreports the size, which matters because this is also what the + * UI shows the user. + * + * NEVER SCALES UP. A source already inside both limits comes back untouched — upscaling costs + * bytes and tokens and adds no information. + */ +function fitToTier(w, h, tier) { + const t = TIERS[tier] || TIERS.standard; + if (!(w > 0) || !(h > 0)) { return { w: 0, h: 0, tokens: 0, scaled: false }; } + + let hi = Math.min(1, t.edge / Math.max(w, h)); + const at = (s) => [Math.round(w * s), Math.round(h * s)]; + + if (visualTokens(...at(hi)) <= t.tokens) { + const [ow, oh] = at(hi); + return { w: ow, h: oh, tokens: visualTokens(ow, oh), scaled: hi < 1 }; + } + let lo = 0; + for (let i = 0; i < 60; i++) { + const mid = (lo + hi) / 2; + if (visualTokens(...at(mid)) <= t.tokens) { lo = mid; } else { hi = mid; } + } + const [ow, oh] = at(lo); + return { w: ow, h: oh, tokens: visualTokens(ow, oh), scaled: true }; +} + +/** + * The scale the CLIENT should apply before sending, for a configured long-edge cap. + * + * Separate from fitToTier on purpose. The server caps cost whatever we do, so this is not a + * safety measure — it is a deliberate fidelity-for-cost trade the user can configure, and a + * defence against the wire (bytes, latency, the request size limit). + * + * Returns exactly 1 when nothing should happen, so the caller can skip re-encoding entirely and + * forward the original bytes. Re-encoding an untouched image only stacks compression artifacts, + * which is worst on the screenshots of text that are most of what gets pasted. + */ +function clientScale(w, h, cap) { + if (!(cap > 0) || !(w > 0) || !(h > 0)) { return 1; } + return Math.min(1, cap / Math.max(w, h)); +} + +/** Apply clientScale, rounded to whole pixels. Never larger than the source. */ +function clientTarget(w, h, cap) { + const s = clientScale(w, h, cap); + return s === 1 ? { w, h, scaled: false } : { w: Math.round(w * s), h: Math.round(h * s), scaled: true }; +} + +/** + * What one image block costs the context meter. + * + * This exists because estimateMsgTokens measures JSON.stringify().length / 4, which is sound for + * text and catastrophic for an image: base64 books about a third of its byte count as tokens, so a + * 1MB screenshot reads as ~333,000 — larger than most context windows — for something that really + * costs ~4,800. With bytes on disk and only a ref in the message the same estimator swings the + * other way and under-counts a ~1800-token image as ~18. Both are wrong; this is the number. + * + * Scope, checked rather than assumed (a reviewer caught an earlier overstatement): today this only + * misreports the UI meter — findCompactionCut cuts on message count and goal boundaries and never + * reads a token number. It becomes a correctness bug the day anything automatic keys off it. + */ +function imageBlockTokens(block, modelId) { + if (!block || block.type !== 'image') { return 0; } + const w = Number(block.w) || 0, h = Number(block.h) || 0; + if (!w || !h) { return TIERS[tierFor(modelId)].tokens; } // unknown size: assume the cap, never zero + return fitToTier(w, h, tierFor(modelId)).tokens; +} + +module.exports = { PATCH, TIERS, visualTokens, tierFor, fitToTier, clientScale, clientTarget, imageBlockTokens }; diff --git a/extensions/levelcode-ai/imageStore.js b/extensions/levelcode-ai/imageStore.js new file mode 100644 index 0000000..bb5e7c5 --- /dev/null +++ b/extensions/levelcode-ai/imageStore.js @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Pasted images on disk — content-addressed, beside the session that used them. + * + * LOCAL, SESSION-ATTACHED. Nothing is uploaded. A screenshot of someone's proprietary code + * never leaves their machine, which is also the only shape that works for BYOK, where the + * editor talks to the provider directly and a detour through our infrastructure would both add + * a failure mode and contradict the promise that we are not in the middle. + * + * WHY A SIBLING DIRECTORY RATHER THAN INLINE BASE64. Claude Code inlines image bytes in its + * own JSONL transcript and that works fine there. It does not work here, and the reason is + * specific to this codebase: sessionStore.scanProject readFileSync + JSON.parses EVERY session + * file in a project whenever index.json is missing, malformed, or on an older schema — which + * happens on first run and after any schema bump. Inlined bytes would make drawing a list of + * session titles parse every screenshot in every session. Refs keep that scan cheap, keep the + * transcript greppable, and dedupe the re-paste that follows a failed send. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +/** Claude accepts exactly these. Anything else is refused before it reaches a provider. */ +const MEDIA_EXT = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp' +}; + +/** + * Per-image ceiling. The Claude API's own limit is 10MB of base64 (5MB on Bedrock and Vertex), + * and base64 inflates by 4/3 — so 5MB of BYTES is the largest thing that is safe everywhere. + * Normalization should keep real pastes far under this; the cap is for the pathological file. + */ +const MAX_BYTES = 5 * 1024 * 1024; + +function mediaDir(root, slug) { return path.join(root, slug, 'media'); } +function refPath(root, slug, ref) { return path.join(mediaDir(root, slug), ref); } + +/** true for a ref this module could have produced — 64 hex chars, a known extension, no path parts. */ +function isRef(ref) { + return typeof ref === 'string' && /^[0-9a-f]{64}\.(png|jpg|gif|webp)$/.test(ref); +} + +/** + * Store bytes and return the ref that identifies them. + * + * Content-addressed: the same screenshot pasted twice is one file, which is exactly what happens + * when someone re-pastes after a send fails. Writing is skipped when the file already exists, so + * a duplicate paste costs a hash and a stat. + */ +function put(root, slug, base64, mediaType) { + const ext = MEDIA_EXT[mediaType]; + if (!ext) { throw new Error('imageStore: unsupported media type: ' + String(mediaType)); } + const buf = Buffer.from(String(base64 || ''), 'base64'); + if (!buf.length) { throw new Error('imageStore: empty image'); } + if (buf.length > MAX_BYTES) { + throw new Error('imageStore: image is ' + Math.round(buf.length / 1024) + 'KB, over the ' + + Math.round(MAX_BYTES / 1024) + 'KB limit'); + } + const ref = crypto.createHash('sha256').update(buf).digest('hex') + '.' + ext; + const dest = refPath(root, slug, ref); + if (!fs.existsSync(dest)) { + fs.mkdirSync(mediaDir(root, slug), { recursive: true }); + // tmp + rename: a crash mid-write must never leave a truncated file under a hash that + // claims to describe its full contents. + const tmp = dest + '.' + process.pid + '.tmp'; + fs.writeFileSync(tmp, buf); + fs.renameSync(tmp, dest); + } + return { ref, bytes: buf.length }; +} + +/** Read bytes back as base64 for a provider request. Returns null when the file is gone. */ +function read(root, slug, ref) { + if (!isRef(ref)) { return null; } + try { return fs.readFileSync(refPath(root, slug, ref)).toString('base64'); } + catch { return null; } +} + +/** The media type a ref implies, from its extension. */ +function mediaTypeOf(ref) { + if (!isRef(ref)) { return null; } + const ext = ref.slice(ref.lastIndexOf('.') + 1); + return Object.keys(MEDIA_EXT).find((k) => MEDIA_EXT[k] === ext) || null; +} + +/** + * A stored `{type:'image', ref, …}` block → the Anthropic wire block, bytes and all. + * + * Called only when a request is being built, and the result is never retained: the conversation, + * the session log and the token meter all keep the ref. Throws when the file is missing, because + * a request that silently drops its subject is the failure this whole feature exists to avoid. + */ +function materialize(root, slug, block) { + if (!block || block.type !== 'image') { return block; } + if (block.source) { return block; } // already materialized (or an inline block from elsewhere) + const data = read(root, slug, block.ref); + if (!data) { throw new Error('imageStore: attached image is missing from disk: ' + String(block.ref)); } + return { type: 'image', source: { type: 'base64', media_type: mediaTypeOf(block.ref), data } }; +} + +/** Refs still referenced by these messages — the keep-set for a sweep. */ +function refsIn(msgs) { + const out = new Set(); + for (const m of (Array.isArray(msgs) ? msgs : [])) { + for (const b of (Array.isArray(m && m.content) ? m.content : [])) { + if (b && b.type === 'image' && isRef(b.ref)) { out.add(b.ref); } + } + } + return out; +} + +/** + * Delete media nothing refers to any more. + * + * Needed because nothing else deletes it. Sessions are append-only and `trash()` only writes a + * lifecycle event — the transcript stays on disk — so "the images go away with the session" was + * never true. And a normal (non-agent) chat writes media without ever calling recordTurn, so its + * refs are not in any session file at all. + * + * That second case is why there is an AGE FLOOR rather than a plain unreferenced-means-delete rule: + * a file written moments ago may belong to a live conversation whose refs have not been persisted + * and may never be. Deleting those would break the open chat. A week is long past the point where a + * conversation is still live, and it bounds the growth, which is the actual complaint. + * + * @param keep a Set of refs still referenced (from refsIn over the project's sessions) + */ +function sweep(root, slug, keep, maxAgeMs) { + const dir = mediaDir(root, slug); + const cutoff = Date.now() - (maxAgeMs > 0 ? maxAgeMs : 7 * 24 * 60 * 60 * 1000); + let removed = 0, bytes = 0; + let names; + try { names = fs.readdirSync(dir); } catch { return { removed: 0, bytes: 0 }; } + for (const name of names) { + if (!isRef(name)) { continue; } // never touch anything we did not write + if (keep && keep.has(name)) { continue; } + const full = path.join(dir, name); + try { + const st = fs.statSync(full); + if (st.mtimeMs > cutoff) { continue; } // young enough to belong to a live conversation + fs.unlinkSync(full); + removed++; bytes += st.size; + } catch { /* raced with another window, or already gone — either way, nothing to do */ } + } + return { removed, bytes }; +} + +module.exports = { MEDIA_EXT, MAX_BYTES, mediaDir, refPath, isRef, put, read, mediaTypeOf, materialize, refsIn, sweep }; diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index aacec4e..06884cc 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -65,6 +65,79 @@ calc above already insets by --shell-x. Set margin-block in these rules, never margin. test/webviewCss.test.js pins this for the whole list. */ + #attachImg .ci { width: 14px; height: 14px; display: block; } + #attachImg.atcap { opacity: .45; } + + /* An attachment problem, said where it happened. Sits under the chips so it reads as belonging to + the thing that failed, and takes no space when there is nothing to say. */ + #imgnote { + font-size: 11.5px; line-height: 1.45; padding: 5px 8px; margin: 2px 0 0; + border-radius: 5px; color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); + background: var(--vscode-inputValidation-warningBackground, rgba(224,160,48,.14)); + border: 1px solid var(--vscode-inputValidation-warningBorder, rgba(224,160,48,.45)); + } + #imgnote[hidden] { display: none; } + + /* The placeholder that stands in while a big screenshot is being decoded and resized. */ + .chip.imgchip.loading { opacity: .8; } + .imgskel { + width: 28px; height: 28px; border-radius: 3px; + background: linear-gradient(90deg, rgba(127,127,127,.14) 25%, rgba(127,127,127,.30) 37%, rgba(127,127,127,.14) 63%); + background-size: 300% 100%; animation: imgskel 1.1s ease-in-out infinite; + } + @keyframes imgskel { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } + @media (prefers-reduced-motion: reduce) { .imgskel { animation: none; } } + + /* What it will cost. Quiet, but present — this product bills credits per turn and an image is + not a rounding error. */ + .chip.imgchip .imgcost { + opacity: .55; margin-left: 5px; padding: 1px 4px; border-radius: 3px; + background: rgba(127,127,127,.16); font-size: 10px; + } + .chip.imgchip .imgthumb { cursor: zoom-in; } + + /* Click a thumbnail (in the tray or the transcript) to see it full size. A 28px thumb cannot tell + you WHICH screenshot you attached, and that is the one thing you want to check before sending. */ + #imgzoom { + position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,.72); cursor: zoom-out; padding: 32px; + } + #imgzoom[hidden] { display: none; } + #imgzoom img { max-width: 100%; max-height: 100%; border-radius: 6px; box-shadow: 0 8px 40px rgba(0,0,0,.5); } + #attachImg { display: inline-flex; align-items: center; justify-content: center; } + + /* ---- attached images ---- */ + /* The chip carries its own thumbnail: an attachment you cannot see is one you cannot check before + sending, and a screenshot is the one attachment where the wrong one looks exactly like the right + one in a filename. */ + .chip.imgchip { padding: 2px 6px 2px 2px; gap: 6px; align-items: center; } + .chip.imgchip .imgthumb { + width: 28px; height: 28px; object-fit: cover; border-radius: 3px; + display: block; background: rgba(127,127,127,.18); + } + .chip.imgchip .imgmeta { font-variant-numeric: tabular-nums; opacity: .75; } + /* A bigger, more findable remove target than the text chips carry. Two reasons it can be: + the image chip is twice the height of a text chip, so 22px fits without changing the row; and + removing the WRONG attachment is cheap to undo on a file pin and annoying on a screenshot you + have to go and take again. Resting opacity is higher too — an affordance you have to hunt for + reads as one that is not there. */ + .chip.imgchip .imgx { + width: 22px; height: 22px; font-size: 15px; line-height: 1; + opacity: .75; margin-left: 3px; + } + .chip.imgchip .imgx:hover { opacity: 1; background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.45)); } + .chip.imgchip .imgx:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; opacity: 1; } + .msgimg { + display: block; max-width: min(320px, 100%); max-height: 240px; width: auto; height: auto; cursor: zoom-in; + border-radius: 6px; border: 1px solid var(--border); margin: 0 0 8px; + } + /* Whole-panel drop target: the transcript is a far bigger target than the composer, and someone + dragging a screenshot aims at the conversation. */ + body.dropping::after { + content: ''; position: fixed; inset: 6px; border: 2px dashed var(--accent); + border-radius: 10px; pointer-events: none; z-index: 40; + } + /* ---- conversation log ---- */ /* The measure, the type and the inset are all declared on `body` (see above) so the composer and the status row resolve the same values the transcript does. `--shell-x` is the log's horizontal padding @@ -455,7 +528,11 @@ .tl-group.gok .tl-node .ci, .tl-group.gfailed .tl-node .ci { width: 18px; height: 18px; } @keyframes nodepulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 40%, transparent); } 70%, 100% { box-shadow: 0 0 0 6px transparent; } } .tl-cmd .cmdhead { display: flex; align-items: center; gap: 7px; min-width: 0; cursor: pointer; user-select: none; padding: 2px 0; } - .tl-cmd .cmdchev { flex: 0 0 auto; display: inline-flex; color: var(--muted); } + /* The chevron TRAILS what it discloses, matching the reference: "Ran 2 commands ⌄", not + "⌄ Ran 2 commands". It hugs the label rather than right-aligning to the card edge — a + disclosure control belongs next to the thing it opens, and a chevron alone at the far right of + a wide row reads as unrelated chrome. */ + .tl-cmd .cmdchev { flex: 0 0 auto; display: inline-flex; color: var(--muted); margin-left: 1px; } .tl-cmd .cmdchev .ci { width: 11px; height: 11px; transition: transform .15s ease; } .tl-cmd.collapsed .cmdchev .ci { transform: rotate(-90deg); } .tl-cmd .cmdverb { flex: 0 0 auto; font-size: 12.5px; color: var(--vscode-foreground); } @@ -485,10 +562,53 @@ .tl-group .groupstop[hidden] { display: none; } .tl-group .groupstop:hover { color: var(--vscode-errorForeground, #f14c4c); } .tl-group .groupstop .ci { width: 12px; height: 12px; } - .tl-group .groupbody { padding: 2px 0 0 4px; } + /* ── A group is a LINE OF TEXT, not a card on a rail ────────────────────────────────────────── + The rail threads consecutive tool nodes into one connected line, which is right for loose + nodes. A group is already one unit, so once its body became a bordered container the rail was + a second grouping cue doing the same job — a circled glyph, a vertical line AND a box around + three rows of grey text. That is what stopped reading as consistent with the prose around it. + So for groups: no rail, no circled node, and the summary sits on the same left edge as the + paragraph above it. The outcome moves into the line itself, quietly. */ + .tl-group > .tl-rail { display: none; } + .tl-group { margin-left: 0; } + .tl-group .grouphead { gap: 7px; padding: 2px 0; } + /* Muted, like every other piece of secondary chrome — a finished group is history, not news. */ + .tl-group.gok .grouplabel { color: var(--muted); font-weight: 400; } + .tl-group.gok .cmdchev { color: var(--muted); opacity: .8; } + /* A failure still speaks up. */ + .tl-group.gfailed .grouplabel { color: var(--vscode-errorForeground, #f14c4c); } + .tl-group .groupmark { flex: 0 0 auto; display: inline-flex; align-items: center; opacity: .75; } + .tl-group .groupmark .ci, .tl-group .groupmark svg { width: 12px; height: 12px; } + .tl-group.gok .groupmark { color: var(--vscode-gitDecoration-addedResourceForeground, #4ec97a); } + .tl-group.gfailed .groupmark { color: var(--vscode-errorForeground, #f14c4c); } + .tl-group.running .groupmark { display: none; } + + /* The expanded steps read as ONE unit. With the rail gone this is the only grouping cue, which + is the point — one device, not three. */ + .tl-group .groupbody { + padding: 0; margin: 6px 0 2px; + border: 1px solid var(--border); border-radius: 8px; + /* NO fill. A tinted block plus a border is two containers drawn on top of each other, and it is + what made these rows read as a solid slab rather than as a list. The hairline does the work; + the rows keep the page's own ground. */ + background: transparent; + overflow: hidden; /* so the first and last rows clip to the radius instead of squaring it off */ + } + .tl-group .groupbody:empty { display: none; } + /* Rows separate by hairline rather than by gap, and each one is INSET from the border. Text that + starts a couple of pixels off the edge reads as overflowing its container even when it is not — + the padding is what makes a bordered list look placed rather than crammed. */ + .tl-group .groupbody > .tl { padding: 3px 14px; } + .tl-group .groupbody > .tl + .tl { border-top: 1px solid var(--line-soft, rgba(127,127,127,.16)); } .tl-group.collapsed .groupbody { display: none; } .tl-group .groupbody > .tl > .tl-rail { display: none; } .tl-group .groupbody > .tl { margin: 0; } + /* A row inside the container is a single line, so it does not need the timeline's asymmetric + bottom padding — that gap is there to separate free-standing nodes from the next one. */ + .tl-group .groupbody > .tl > .tl-body { padding: 4px 0; } + /* The summary lines up with the rows below it rather than with the container edge, so the + expanded state reads as one block instead of two things that happen to be stacked. */ + .tl-group .grouphead { padding-left: 0; } /* Copilot-style falling-dots progress — a 2×3 dot grid cascading downward while a command runs */ .lcdots { display: inline-grid; grid-template-columns: repeat(2, 3px); gap: 2px; color: var(--accent); align-self: center; } .lcdots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: lcdotfall 1.1s ease-in-out infinite; } @@ -1306,12 +1426,18 @@ never a gap between actions where nothing shows. Its label tracks the current activity. --> + +
+
+ '; } + /** What this image will actually cost the request, in visual tokens (28px patches, tier-capped). */ + function imgTokens(w, h){ + if (!(w > 0) || !(h > 0)) { return 0; } + const cap = 4784; // high-res tier ceiling; the server caps here whatever we send + let s2 = 1, t = Math.ceil(w / 28) * Math.ceil(h / 28); + while (t > cap && s2 > 0.05) { s2 *= 0.98; t = Math.ceil(w * s2 / 28) * Math.ceil(h * s2 / 28); } + return t; + } + + /** + * An attached image: its thumbnail, its size, what it will cost, and a way to take it back off. + * + * The cost is shown because this product meters credits per turn and an image is not a rounding + * error — a full-size screenshot is a few thousand input tokens. Someone deciding whether to + * attach three of them should be able to see that before they send, not after they are billed. + */ + function imgChip(im){ + if (im.loading) { + return '' + + '' + + 'preparing…' + + ''; + } + const tok = imgTokens(im.w, im.h); + // Compact on the chip, exact in the tooltip. A tray of attachments is scanned, not read — and + // three chips at full width wrap onto a second row, which makes the composer jump as you paste. + const short = tok >= 1000 ? (tok / 1000).toFixed(1) + 'k' : String(tok); + const title = im.w + '×' + im.h + ' · ' + fmtKB(im.bytes) + ' · ~' + + tok.toLocaleString(CREDIT_LOCALE) + ' input tokens · click to view full size'; + return '' + + '' + + '' + im.w + '×' + im.h + '' + short + '' + + '×' + + ''; + } + function renderChips(){ let h = ''; if (activeFileLabel) { h += fileChip(activeFileLabel); } for (const f of ctxFiles) { h += pinnedChip(f); } if (selLabel) { h += selChip(selLabel); } + for (const im of pendingImages) { h += imgChip(im); } chipsEl.innerHTML = h; + syncAttachImgBtn(); + chipsEl.querySelectorAll('.imgx').forEach(function(x){ + const go = function(e){ e.stopPropagation(); e.preventDefault(); removeImage(x.getAttribute('data-img')); }; + x.onclick = go; + // It is role="button" tabindex="0", so it can be focused — without this it could be reached + // by keyboard and then not activated, which is worse than not being reachable at all. + x.onkeydown = function(e){ if (e.key === 'Enter' || e.key === ' ') { go(e); } }; + }); document.getElementById('addctx').onclick = () => vscode.postMessage({ type: 'addContext' }); - chipsEl.querySelectorAll('.x').forEach(x => { + // :not(.imgx) — an image chip's × carries BOTH classes, and both handlers assign .onclick + // rather than adding a listener, so whichever runs last silently wins. It did: clicking × on an + // image posted removeContext with a null id and the image stayed. Excluding here rather than + // reordering, so it cannot come back the next time these two blocks move relative to each other. + chipsEl.querySelectorAll('.x:not(.imgx)').forEach(x => { x.onclick = (e) => { e.stopPropagation(); vscode.postMessage({ type: 'removeContext', id: x.getAttribute('data-id') }); }; }); } @@ -1926,7 +2101,37 @@ if (lists){ parts.push('listed files'); } if (cmds){ parts.push('ran ' + (cmds === 1 ? 'a command' : cmds + ' commands')); } if (steps.some((s) => s.kind === 'verify')){ parts.push('verified the edits'); } - if (!parts.length){ return steps.length === 1 ? '1 step' : steps.length + ' steps'; } + + // Setup. Named LAST so real work still leads the sentence, but named — a run whose only steps + // were setup should say what it set up, not how many things it did. + const kinds = new Set(steps.map((s) => s.kind)); + const ctxBits = []; + if (kinds.has('rules')) { ctxBits.push('project rules'); } + if (kinds.has('memory') || kinds.has('recall')) { ctxBits.push('memory'); } + if (ctxBits.length) { parts.push('loaded ' + ctxBits.join(' and ')); } + if (kinds.has('skill')) { + const names = uniq(steps.filter((s) => s.kind === 'skill').map((s) => s.path).filter(Boolean)); + parts.push(names.length ? ('used the ' + files(names) + ' skill' + (names.length > 1 ? 's' : '')) : 'used a skill'); + } + const calls = steps.filter((s) => s.kind === 'mcpcall'); + if (calls.length) { + const servers = uniq(calls.map((s) => s.path).filter(Boolean)); + parts.push('called ' + (servers.length ? files(servers) : (calls.length + ' MCP tools'))); + } else if (kinds.has('mcp')) { + parts.push('connected MCP tools'); + } + if (kinds.has('preview')) { parts.push('opened a preview'); } + + if (!parts.length){ + // Last resort: name the FIRST thing rather than count. "3 steps" tells no one anything. + const first = steps.find((s) => s.base && s.base.trim()); + if (first){ + const rest = steps.length - 1; + const one = first.base.trim() + (rest > 0 ? (' and ' + rest + ' more') : ''); + return one.charAt(0).toUpperCase() + one.slice(1); // same sentence-casing as the built path + } + return steps.length === 1 ? '1 step' : steps.length + ' steps'; + } const sentence = parts.join(', '); return sentence.charAt(0).toUpperCase() + sentence.slice(1); } @@ -1944,6 +2149,20 @@ } if (kind === 'search' || icon === 'search'){ return { kind: 'search', base: titled || 'Search the workspace' }; } if (icon === 'list-tree'){ return { kind: 'list', base: titled || 'List files' }; } + // The run's SETUP steps. These arrive as pre-baked emoji chips and used to fall through to + // `note`, which contributes no phrase — so a run that loaded rules, memory and an MCP server + // summarised as the useless "3 steps". They describe real work and should say so. + if (/^📋 project rules/.test(t)){ return { kind: 'rules', base: titled || t, path: t.replace(/^📋 project rules · /, '') }; } + if (/^🧠 project memory/.test(t)){ return { kind: 'memory', base: titled || t }; } + if (/^🧠 recalling:/.test(t)){ return { kind: 'recall', base: titled || t }; } + if (/^🧩 using skill:/.test(t)){ return { kind: 'skill', base: titled || t, path: t.replace(/^🧩 using skill: /, '') }; } + if (/^🔌 /.test(t)){ + // A tool CALL through a server (🔌 name · tool) is different work from setup chatter. + const call = /^🔌 (?!mcp · )([^·]+) · (.+)$/.exec(t); + return call ? { kind: 'mcpcall', base: titled || t, path: call[1].trim() } + : { kind: 'mcp', base: titled || t }; + } + if (/^🌐 preview/.test(t)){ return { kind: 'preview', base: titled || t }; } return { kind: 'note', base: titled || t }; } // The label for a command step: the model's explanation (imperative, per the system prompt), or @@ -1965,8 +2184,9 @@ '
' + codicon('sync') + '
' + '
' + '
' - + '' + codicon('chevron-down') + '' + + '' + '' + + '' + codicon('chevron-down') + '' + '' + '' + '' + LC_DOTS + '' @@ -2049,7 +2269,11 @@ // ONE outcome glyph, in the rail. While running the rail carried a spinner and the head carried // dots; a finished group must not go on wearing either. The rail states the outcome, so the // trailing state clears on success and speaks up only when something failed. + // The outcome lives IN the line now, not on a rail: a small glyph ahead of the summary, the + // same weight as the text it sits beside. The rail node is still set for the ungrouped path. if (g.node){ g.node.innerHTML = codicon(g.failed ? 'circle-slash' : 'check-circle'); } + const mark = g.el.querySelector('.groupmark'); + if (mark){ mark.innerHTML = codicon(g.failed ? 'circle-slash' : 'check'); } g.state.innerHTML = g.failed ? codicon('circle-slash') : ''; g.state.className = 'cmdstate groupstate ' + (g.failed ? 'bad' : 'ok'); g.stopBtn.hidden = true; @@ -2367,8 +2591,8 @@ '
' + codicon('terminal') + '
' + '
' + '
' - + '' + codicon('chevron-down') + '' + '' + (m.background ? 'Running in background' : 'Running') + '' + + '' + codicon('chevron-down') + '' + '' + chips + '' + '' + LC_DOTS + '' + '
' @@ -3055,22 +3279,275 @@ function caretAtFirstLine(el){ return el.value.lastIndexOf('\n', el.selectionStart - 1) === -1; } function caretAtLastLine(el){ return el.value.indexOf('\n', el.selectionEnd) === -1; } function histSet(text){ input.value = text; auto(); const n = text.length; try { input.setSelectionRange(n, n); } catch (e) {} } - function doSend(){ + async function doSend(){ if (streaming) { flushAll = true; ensurePump(); vscode.postMessage({ type: 'stop' }); return; } - const t = input.value.trim(); if (!t) return; - cmdHistory.push(t); if (cmdHistory.length > 200) { cmdHistory.shift(); } // record for ↑/↓ recall + const t = input.value.trim(); + // An image with no words is a real message — "look at this" is implied by attaching it. + if (!t && !pendingImages.length) return; + + // Wait for anything still decoding. A placeholder chip carries no bytes, so sending now would + // post an attachment with undefined media_type and base64 — refused host-side, and the image + // would vanish from a message the user watched themselves attach. + if (inflightImages.size) { + note('Still preparing ' + inflightImages.size + ' image' + (inflightImages.size === 1 ? '' : 's') + '…'); + for (let i = 0; i < 200 && inflightImages.size; i++) { await new Promise(function(r){ setTimeout(r, 50); }); } + const el = document.getElementById('imgnote'); if (el) { el.hidden = true; } + if (inflightImages.size) { note('An image is taking too long to prepare — remove it or try again.'); return; } + } + // Nothing may be sent while a placeholder survives: a decode that failed silently would + // otherwise ride along as an empty attachment. + if (pendingImages.some(function(i){ return i.loading; })) { + note('An image did not finish preparing — remove it and try again.'); return; + } + + // RE-CHECK the model. The attach-time gate is not enough: a model can be switched between + // attaching and sending, and this path would otherwise hand images to a model that cannot read + // them. Refuse without discarding anything the user typed or attached. + if (pendingImages.length && !canSeeImages) { + note((canSeeImagesModel || 'The selected model') + ' cannot read images. Switch back to a vision model, or remove the attachments.'); + return; + } + if (t) { cmdHistory.push(t); if (cmdHistory.length > 200) { cmdHistory.shift(); } } // record for ↑/↓ recall histIdx = -1; histDraft = ''; // Slash commands — handled locally (deterministic, no LLM call). if (/^\/skills\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listSkills' }); return; } if (/^\/mcp\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listMcp' }); return; } if (/^\/sessions\b/i.test(t)){ input.value = ''; auto(); openSessions(); return; } if (/^\/rme\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); addReminder(); return; } - add('user', render(t)); forceStick(); input.value = ''; auto(); - vscode.postMessage({ type: 'send', text: t }); + const imgs = pendingImages.map(function(i){ + return { media_type: i.media_type, base64: i.base64, w: i.w, h: i.h, bytes: i.bytes }; + }); + add('user', (imgs.length ? imgs.map(function(i, k){ + return 'attached image'; + }).join('') : '') + render(t)); + forceStick(); input.value = ''; auto(); clearImages(); renderChips(); + vscode.postMessage({ type: 'send', text: t, images: imgs }); + } + + // ── attached images ─────────────────────────────────────────────────────────────────────────── + // Paste a screenshot, ask about it. Normalization happens HERE, before the bytes cross to the + // host, because this is the only place that has the decoded pixels. + // + // THE CAP IS 2000px ON THE LONG EDGE, and it is not a guess. Claude Code ships exactly this — + // every image it re-encodes is 2000 on the long edge — and it is the threshold the vision docs + // name for staying clear of the stricter per-image dimension limit that applies once a request + // carries more than 20 images. It is also well above either model tier's own cap, so the server + // does the final downscale and we never throw away fidelity it would have kept. + // + // UNDER THE CAP, THE ORIGINAL BYTES GO THROUGH UNTOUCHED — same rule, same reason: re-encoding + // an image that did not need resizing only stacks compression artifacts, and that is worst on + // screenshots of code, which is most of what gets pasted. + // Whether the SELECTED model can read images. Pushed with the model so an attachment is refused + // before anything is typed and lost, rather than after a send the provider would reject. + let canSeeImages = false; + let canSeeImagesModel = ''; + const IMG_CAP = 2000; + // How many images may ride one message. Configurable because the cost is real: each image is + // ~1,800-3,000 input tokens, so a maxed-out message is a five-figure prompt before a word is typed. + let IMG_MAX_PER_TURN = 5; + const IMG_OK = { 'image/png': 1, 'image/jpeg': 1, 'image/gif': 1, 'image/webp': 1 }; + let pendingImages = []; // { id, url, w, h, media_type, base64, bytes } + let imgSeq = 0; + // Normalization is async, and a placeholder chip carries no bytes. Sending mid-decode would post + // an attachment with undefined media_type/base64 — the host would refuse it and the image would + // be silently lost from a message the user watched themselves attach. Track the work so doSend + // can wait for it. + const inflightImages = new Set(); + + /** + * A user-facing "that did not work" line, shown WHERE IT HAPPENED — directly under the composer, + * beside the attachment that failed. + * + * This used to post to the host and surface as a VS Code notification in the far corner of the + * window. That is the wrong place for it: the notification appears seconds after the paste, a + * long way from the thing the person is looking at, and it outlives the moment it describes. + * Attachment problems are small, immediate and local, so the message should be too. + */ + let noteTimer = null; + function note(t){ + const text = String(t || ''); if (!text) { return; } + const el = document.getElementById('imgnote'); if (!el) { return; } + el.textContent = text; + el.hidden = false; + clearTimeout(noteTimer); + // Long enough to read a sentence; short enough that it does not linger over the next attempt. + noteTimer = setTimeout(function(){ el.hidden = true; }, 6000); + } + + function fmtKB(n){ return n >= 1024 * 1024 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB'; } + + /** Blob -> base64 payload, no data: prefix. */ + function blobToBase64(blob){ + return new Promise(function(res, rej){ + const r = new FileReader(); + r.onload = function(){ const s2 = String(r.result || ''); res(s2.slice(s2.indexOf(',') + 1)); }; + r.onerror = function(){ rej(new Error('could not read the image')); }; + r.readAsDataURL(blob); + }); + } + + /** Decode, downscale only if over the cap, re-encode only if we resized. Never scales up. */ + async function normalizeImage(file){ + if (!IMG_OK[file.type]) { throw new Error('that image format is not supported — use PNG, JPEG, GIF or WebP'); } + let bmp; + try { bmp = await createImageBitmap(file); } + catch (e) { throw new Error('that file could not be read as an image'); } + const w = bmp.width, h = bmp.height; + const scale = Math.min(1, IMG_CAP / Math.max(w, h)); + if (scale === 1) { + // Pass-through: the bytes we already have, in the format they arrived in. + bmp.close && bmp.close(); + return { w: w, h: h, media_type: file.type, base64: await blobToBase64(file), bytes: file.size }; + } + const tw = Math.round(w * scale), th = Math.round(h * scale); + // OffscreenCanvas keeps the decode and the draw off the layout path; a 4K decode on the main + // thread is a visible stall in a chat window. + const cv = (typeof OffscreenCanvas !== 'undefined') ? new OffscreenCanvas(tw, th) + : Object.assign(document.createElement('canvas'), { width: tw, height: th }); + const g = cv.getContext('2d'); + g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'; + g.drawImage(bmp, 0, 0, tw, th); + bmp.close && bmp.close(); + const blob = cv.convertToBlob ? await cv.convertToBlob({ type: 'image/webp', quality: 0.92 }) + : await new Promise(function(r){ cv.toBlob(r, 'image/webp', 0.92); }); + if (!blob) { throw new Error('the image could not be resized'); } + return { w: tw, h: th, media_type: 'image/webp', base64: await blobToBase64(blob), bytes: blob.size }; + } + + /** Host-read bytes -> a File, so a picked/dropped path goes through the same normalizer as a paste. */ + function b64ToFile(b64, type, name){ + const bin = atob(b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { u8[i] = bin.charCodeAt(i); } + return new File([u8], name || 'image', { type: type }); + } + + async function attachImageFiles(files){ + const list = Array.from(files || []).filter(function(f){ return f && /^image\//.test(f.type); }); + if (!list.length) { return false; } + if (!canSeeImages) { + note((canSeeImagesModel || 'This model') + ' cannot read images. Switch to a vision model — ' + + 'Claude, GPT-4o and Gemini all read them — and paste again.'); + return true; + } + for (let fi = 0; fi < list.length; fi++) { + const f = list[fi]; + if (pendingImages.length >= IMG_MAX_PER_TURN) { + // From the position in THIS batch, not from the tray total. Subtracting the tray from the + // batch goes negative the moment the tray already holds more than the batch does — four + // attached, cap five, drop two, and the message claimed "-3 were not attached". + const dropped = list.length - fi; + note('Up to ' + IMG_MAX_PER_TURN + ' images per message. ' + + (dropped > 0 ? dropped + ' not attached — ' : '') + 'remove one to add another.'); + break; + } + // A placeholder goes in FIRST. Decoding and re-encoding a 4K screenshot takes long enough to + // read as "nothing happened", and the honest fix is to show the attachment immediately rather + // than to make the work faster. The chip is replaced in place when the bytes are ready. + const id = 'img' + (++imgSeq); + pendingImages.push({ id: id, loading: true, name: f.name || 'image' }); + renderChips(); + inflightImages.add(id); + try { + const im = await normalizeImage(f); + im.id = id; + im.url = 'data:' + im.media_type + ';base64,' + im.base64; + const at = pendingImages.findIndex(function(x){ return x.id === id; }); + if (at < 0) { continue; } // removed while it was decoding — respect that + pendingImages[at] = im; + renderChips(); + } catch (e) { + pendingImages = pendingImages.filter(function(x){ return x.id !== id; }); + renderChips(); + note(String((e && e.message) || e)); + } finally { + inflightImages.delete(id); + } + } + return true; } + function removeImage(id){ pendingImages = pendingImages.filter(function(i){ return i.id !== id; }); renderChips(); } + + /** Full-size view of an attachment. Escape or a click anywhere closes it. */ + function zoomImage(src){ + const z = document.getElementById('imgzoom'); if (!z || !src) { return; } + z.querySelector('img').src = src; + z.hidden = false; + } + function closeZoom(){ const z = document.getElementById('imgzoom'); if (z) { z.hidden = true; z.querySelector('img').src = ''; } } + document.getElementById('imgzoom').addEventListener('click', closeZoom); + document.addEventListener('keydown', function(e){ + if (e.key === 'Escape' && !document.getElementById('imgzoom').hidden) { e.stopPropagation(); closeZoom(); } + }, true); + // Delegated so it covers both the composer tray and every image already in the transcript. + document.addEventListener('click', function(e){ + const t = e.target; + if (!t || !(t.classList && (t.classList.contains('imgthumb') || t.classList.contains('msgimg')))) { return; } + const im = pendingImages.find(function(x){ return x.id === t.getAttribute('data-img'); }); + zoomImage(im ? im.url : t.getAttribute('src')); + }); + + /** Reflect how full the image tray is on the attach button, so the cap is visible before it bites. */ + function syncAttachImgBtn(){ + const b = document.getElementById('attachImg'); if (!b) { return; } + const n = pendingImages.length, full = n >= IMG_MAX_PER_TURN; + b.classList.toggle('atcap', full); + b.title = full + ? ('Image limit reached (' + n + '/' + IMG_MAX_PER_TURN + ') — remove one to attach another') + : (n ? ('Attach an image (' + n + '/' + IMG_MAX_PER_TURN + ') — or paste with ⌘V') + : 'Attach an image — or paste a screenshot with ⌘V'); + } + function clearImages(){ pendingImages = []; inflightImages.clear(); } + + /** Everything a fresh conversation must forget about attachments. */ + function resetImages(){ clearImages(); renderChips(); const el = document.getElementById('imgnote'); if (el) { el.hidden = true; } } + + // Paste is the primary way in — the whole interaction is screenshot, then Cmd+V. + input.addEventListener('paste', function(ev){ + const d = ev.clipboardData; if (!d) { return; } + const files = d.files && d.files.length ? d.files + : Array.from(d.items || []).filter(function(i){ return i.kind === 'file'; }).map(function(i){ return i.getAsFile(); }); + const imgs = Array.from(files || []).filter(function(f){ return f && /^image\//.test(f.type); }); + if (!imgs.length) { return; } // a normal text paste is untouched + ev.preventDefault(); + attachImageFiles(imgs); + }); + + // Drop anywhere in the panel, not just on the composer — the transcript is the bigger target. + document.addEventListener('dragover', function(ev){ + if (ev.dataTransfer && Array.from(ev.dataTransfer.types || []).indexOf('Files') >= 0) { + ev.preventDefault(); document.body.classList.add('dropping'); + } + }); + document.addEventListener('dragleave', function(ev){ if (!ev.relatedTarget) { document.body.classList.remove('dropping'); } }); + document.addEventListener('drop', function(ev){ + document.body.classList.remove('dropping'); + const dt = ev.dataTransfer; if (!dt) { return; } + const imgs = Array.from(dt.files || []).filter(function(f){ return /^image\//.test(f.type); }); + if (imgs.length) { ev.preventDefault(); attachImageFiles(imgs); return; } + // VS Code's workbench intercepts OS file drops before a webview iframe sees them, so + // dataTransfer.files is usually EMPTY for a drag out of Finder — while the path is still + // there as a uri-list. Hand the paths to the host, which can read them off disk. + let uris = ''; + try { uris = dt.getData('text/uri-list') || dt.getData('text/plain') || ''; } catch (e) {} + const paths = uris.split(/[\r\n]+/) + .map(function(u){ return u.trim(); }) + .filter(function(u){ return u && !/^#/.test(u); }) + .map(function(u){ try { return /^file:/i.test(u) ? decodeURIComponent(u.replace(/^file:\/\//i, '')) : u; } catch (e) { return u; } }) + .filter(function(u){ return /\.(png|jpe?g|gif|webp)$/i.test(u); }); + if (!paths.length) { return; } + ev.preventDefault(); + if (!canSeeImages) { note((canSeeImagesModel || 'This model') + ' cannot read images.'); return; } + vscode.postMessage({ type: 'attachImagePaths', paths: paths }); + }); + sendBtn.onclick = doSend; document.getElementById('attach').onclick = () => vscode.postMessage({ type: 'addContext' }); + document.getElementById('attachImg').onclick = function(){ + if (!canSeeImages) { note((canSeeImagesModel || 'This model') + ' cannot read images. Switch to a vision model and try again.'); return; } + vscode.postMessage({ type: 'pickImages' }); + }; document.getElementById('model').onclick = () => vscode.postMessage({ type: 'pickModel' }); // Approvals dropdown (mirrors the Agent/Chat menu). The host owns the flag (persists it + gates the // danger set); selecting an option posts setAutopilot and we reflect whatever it echoes back via the @@ -3626,6 +4103,8 @@ mEl.title = (m.providerLabel || (m.provider === 'claude' ? 'Claude' : m.provider)) + ' · ' + m.model + ' — click to change'; // Update the footer context-window meter to the newly-selected model's real window. if (typeof m.contextLimit === 'number'){ renderContext({ limit: m.contextLimit }); } + if (typeof m.canSeeImages === 'boolean'){ canSeeImages = m.canSeeImages; canSeeImagesModel = label; } + if (typeof m.maxImages === 'number' && m.maxImages > 0){ IMG_MAX_PER_TURN = m.maxImages; renderChips(); } lastProvider = m.provider || ''; lastModelId = m.modelId || m.model || ''; // gateway sends modelId (the real id); BYOK model IS the id // calm-transcript toggle. Turning it OFF mid-run closes any open group first, so the timeline @@ -3633,6 +4112,9 @@ if (typeof m.groupActivity === 'boolean'){ if (!m.groupActivity){ closeGroup(); } groupsOn = m.groupActivity; } renderRouting(); } + else if (m.type === 'attachImages'){ + attachImageFiles((m.files || []).map(function(f){ return b64ToFile(f.base64, f.media_type, f.name); })); + } else if (m.type === 'assistantStart'){ shown = ''; pending = ''; doneSignaled = false; flushAll = false; current = makeStream(add('assistant', '')); setStreaming(true); } else if (m.type === 'assistantDelta'){ pending += m.text; ensurePump(); } else if (m.type === 'assistantDone'){ doneSignaled = true; ensurePump(); } @@ -3692,6 +4174,7 @@ else if (m.type === 'context'){ selLabel = m.label; renderChips(); } else if (m.type === 'clearContext'){ selLabel = null; renderChips(); } else if (m.type === 'reset'){ + resetImages(); // a new conversation must not inherit the previous one's attachments log.innerHTML = '
New chat
Ask about your code, or describe what to build.
'; // Wiping the log detaches any open group; drop the dangling refs so the next run starts clean — // otherwise groupAppend() appends into a disconnected .groupbody (nothing shows), and a stale diff --git a/extensions/levelcode-ai/package.json b/extensions/levelcode-ai/package.json index abf3e24..ac75eee 100644 --- a/extensions/levelcode-ai/package.json +++ b/extensions/levelcode-ai/package.json @@ -142,6 +142,12 @@ "category": "LevelCode", "icon": "$(book)" }, + { + "command": "levelcode.ai.attachImage", + "title": "AI: Attach Image to Chat", + "category": "LevelCode", + "icon": "$(device-camera)" + }, { "command": "levelcode.ai.sketch", "title": "AI: Agent Sketch…", @@ -234,6 +240,11 @@ "when": "activeWebviewPanelId == 'levelcode.ai.chat'", "group": "navigation@3" }, + { + "command": "levelcode.ai.attachImage", + "when": "resourceExtname =~ /\\.(png|jpe?g|gif|webp)$/", + "group": "navigation@0" + }, { "command": "levelcode.ai.setApiKey", "when": "activeWebviewPanelId == 'levelcode.ai.chat'", @@ -400,6 +411,13 @@ "maximum": 24, "markdownDescription": "Font size for chat **prose** (message bodies), in pixels. `0` tracks the editor's UI font size, one step larger for reading — a 13px workbench gives 14px prose.\n\nThe workbench size is tuned for menu labels and tree rows; message bodies read a step above it so a long answer is comfortable. Controls, cards and the composer always match the workbench exactly. Values are clamped to 8–24." }, + "levelcode.ai.chat.maxImagesPerMessage": { + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 20, + "markdownDescription": "How many images may be attached to one message. Each image costs roughly **1,800–3,000 input tokens**, so a full message is a five-figure prompt before you type a word. Clamped to 1–20 at the boundary — the API's own ceiling is 20 per message." + }, "levelcode.ai.chat.proseWidth": { "type": "number", "default": 0, diff --git a/extensions/levelcode-ai/providers/catalog.js b/extensions/levelcode-ai/providers/catalog.js index 3ee6c76..af2af3c 100644 --- a/extensions/levelcode-ai/providers/catalog.js +++ b/extensions/levelcode-ai/providers/catalog.js @@ -116,6 +116,38 @@ function supportsToolsForModel(providerId, modelId) { return modelCaps(modelId).tools !== false; } +/** + * Whether an image may be attached for this provider+model. + * + * Deliberately STRICTER than supportsToolsForModel. That one defaults unknown models to tools:true, + * because most modern chat models have tools and refusing the agent is the bigger loss. Vision + * inverts that trade: an unknown model is assumed NOT to see. Attaching to a blind model costs the + * user a composed message and returns a provider error (or, worse, a confident answer about text + * alone); a disabled attach button that names a model which can see costs them one click. The + * catalog already marks every vision model we know, and heuristicCaps covers the big families, so + * the strict default bites only on genuinely unrecognised ids. + */ +function supportsVisionForModel(providerId, modelId) { + const p = getProvider(providerId); + if (!p) { return false; } + // The PROVIDER gate first, exactly as supportsToolsForModel does. Without it this checked only + // the model id, so `custom` — an arbitrary OpenAI-compatible endpoint with no declared vision + // capability — returned true for any model whose NAME looked like a vision model, and the + // attachment gate would hand images to an endpoint nobody said could read them. + // + // The registry ENUMERATES vision providers — anthropic, openai, openrouter and the gateway + // declare `vision: true`; ollama and `custom` deliberately do not. Reading only the model id + // ignored that: `custom` is an arbitrary user-supplied endpoint, and any model NAMED like a + // vision model would have been handed images nobody said it could read. + // + // So both halves must agree, exactly as supportsToolsForModel requires both. A custom endpoint + // that does serve a vision model needs `vision: true` on its registry entry to opt in; there is + // deliberately no per-user override, because the honest place to declare a provider's + // capabilities is the provider registry. + if (!(p.caps && p.caps.vision === true)) { return false; } + return modelCaps(modelId).vision === true; +} + /** The model's context window (tokens), or `fallback` (then 200000) when unknown. */ function contextWindowFor(providerId, modelId, fallback) { return modelCaps(modelId).context || fallback || 200000; @@ -238,7 +270,7 @@ async function getModelChoices(providerId, opts) { module.exports = { CAPS, modelCaps, baseName, heuristicCaps, - supportsToolsForModel, contextWindowFor, fastCompletionModel, + supportsToolsForModel, supportsVisionForModel, contextWindowFor, fastCompletionModel, describeCaps, describeModel, mapOpenRouterModels, mapModelIds, fetchModels, getModelChoices }; diff --git a/extensions/levelcode-ai/providers/translate.js b/extensions/levelcode-ai/providers/translate.js index 41df2f0..89e414d 100644 --- a/extensions/levelcode-ai/providers/translate.js +++ b/extensions/levelcode-ai/providers/translate.js @@ -33,6 +33,30 @@ function toOpenAITools(tools) { })); } +/** + * Anthropic image block → OpenAI `image_url` part. + * + * Anthropic carries the bytes in `source` (base64 + media_type, or a url); OpenAI-compatible APIs take + * one `url` field that is either a real URL or a `data:` URI. Everything else about the block is ours. + */ +function toOpenAIImagePart(b) { + const src = b && b.source; + if (!src) { throw new Error('translate: image block has no source'); } + if (src.type === 'url') { + if (!src.url) { throw new Error('translate: image block has a url source with no url'); } + return { type: 'image_url', image_url: { url: src.url } }; + } + if (src.type === 'base64') { + if (!src.media_type || !src.data) { + throw new Error('translate: image block is missing media_type or data'); + } + return { type: 'image_url', image_url: { url: 'data:' + src.media_type + ';base64,' + src.data } }; + } + // `file` (Files API) is Anthropic-only — there is no OpenAI equivalent to translate it to, so + // refuse rather than send a request whose subject is missing. + throw new Error('translate: image source type not supported on this provider: ' + String(src.type)); +} + /** Coerce a tool_result's content (string | array of blocks | other) to a plain string for OpenAI. */ function toolResultText(content) { if (typeof content === 'string') { return content; } @@ -82,6 +106,17 @@ function toOpenAIMessages(system, messages, opts) { function: { name: b.name, arguments: JSON.stringify(b.input == null ? {} : b.input) } }); } + // Reasoning an assistant produced is not something an OpenAI-shaped request carries, + // and re-sending it is not required to continue a conversation — dropping it is a + // deliberate translation, not a loss, so it is named rather than left to the + // fall-through below. + else if (b.type === 'thinking' || b.type === 'redacted_thinking') { continue; } + else { + // Same rule as the user loop. A block type we do not understand is a bug in us, + // and a request that silently loses part of an assistant turn desynchronises the + // conversation the model is asked to continue. + throw new Error('translate: unsupported content block in an assistant message: ' + String(b.type)); + } } const msg = { role: 'assistant', content: text ? text : null }; if (toolCalls.length) { msg.tool_calls = toolCalls; } @@ -92,15 +127,34 @@ function toOpenAIMessages(system, messages, opts) { if (typeof m.content === 'string') { out.push({ role: 'user', content: m.content }); continue; } const blocks = Array.isArray(m.content) ? m.content : []; let trailingText = ''; + const images = []; for (const b of blocks) { if (!b) { continue; } if (b.type === 'tool_result') { out.push({ role: 'tool', tool_call_id: b.tool_use_id, content: toolResultText(b.content) }); } else if (b.type === 'text') { trailingText += (trailingText ? '\n' : '') + (b.text || ''); + } else if (b.type === 'image') { + images.push(toOpenAIImagePart(b)); + } else { + // Loudly, not silently. This loop used to fall through on anything it did not + // recognise, so a block type it had never seen vanished between the composer and + // the wire with no error and no log line — the model would then answer confidently + // about content it was never sent. A type we do not understand is a bug in us. + throw new Error('translate: unsupported content block in a user message: ' + String(b.type)); } } - if (trailingText) { out.push({ role: 'user', content: trailingText }); } + // Images first: the model reads them best before the text that asks about them, and it keeps + // markLastOpenAICacheable's breakpoint on a text block rather than on an image. + if (images.length) { + const content = images.slice(); + if (trailingText) { content.push({ type: 'text', text: trailingText }); } + out.push({ role: 'user', content }); + } else if (trailingText) { + // No image — keep the plain string. Widening every text-only turn to a block array would + // change the bytes of every cached prefix for no gain. + out.push({ role: 'user', content: trailingText }); + } } if (cache) { markLastOpenAICacheable(out); } return out; diff --git a/extensions/levelcode-ai/sessions.js b/extensions/levelcode-ai/sessions.js index b133591..bf02307 100644 --- a/extensions/levelcode-ai/sessions.js +++ b/extensions/levelcode-ai/sessions.js @@ -19,6 +19,7 @@ const store = require('./sessionStore'); const events = require('./sessionEvents'); const planner = require('./sessionResume'); const memory = require('./sessionMemory'); +const imageStore = require('./imageStore'); /** * @param {{ root: string, slug: string, projectPath: string, @@ -326,13 +327,35 @@ function createSessions(opts) { } /** Where this project's memory lives (for opening it — the transparency promise). */ function memoryPaths() { return { dir: memory.memoryDir(root, slug), journal: memory.journalFile(root, slug), memoryMd: memory.memoryMdFile(root, slug) }; } + /** + * Where attached images live: `media/` beside this project's sessions. + * + * PROJECT-scoped, not session-scoped, and nothing removes a file when a session goes away — + * sessions are append-only and `trash()` only writes a lifecycle event. `sweepMedia()` below is + * what actually bounds this. + */ + function mediaRoot() { return { root, slug }; } + + /** + * Delete images no session in this project refers to any more, and that are old enough not to + * belong to a live conversation. Returns { removed, bytes }; never throws. + */ + function sweepMedia(maxAgeMs) { + try { + const keep = new Set(); + for (const entry of list()) { + for (const ref of imageStore.refsIn(transcript(entry.id) || [])) { keep.add(ref); } + } + return imageStore.sweep(root, slug, keep, maxAgeMs); + } catch (e) { return { removed: 0, bytes: 0 }; } + } /** The session index for this project (what the panel/view list). Empty (never throws) if unreadable. */ function list() { try { return store.loadIndex(root, slug).entries; } catch (e) { return []; } } function liveId() { return live ? live.id : null; } - return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, list, liveId }; + return { ensure, recordTurn, seal, resume, fork, archive, trash, restore, setPinned, rename, autoArchiveStale, digest, consolidate, transcript, refineSummary, recall, recallFacts, memoryItems, forget, recordFacts, factsList, factAction, supersedeFact, memoryPaths, mediaRoot, sweepMedia, list, liveId }; } module.exports = { createSessions }; diff --git a/extensions/levelcode-ai/test/contextAnnounce.test.js b/extensions/levelcode-ai/test/contextAnnounce.test.js new file mode 100644 index 0000000..73998b9 --- /dev/null +++ b/extensions/levelcode-ai/test/contextAnnounce.test.js @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * The run's context is announced when it CHANGES, not every turn. + * run: node test/contextAnnounce.test.js + * + * Source-extraction: agent.js requires `vscode`, so the rule is pinned against the source rather + * than by running runAgent. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const agent = fs.readFileSync(path.join(__dirname, '..', 'agent.js'), 'utf8'); +const ext = fs.readFileSync(path.join(__dirname, '..', 'extension.js'), 'utf8'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +test('COLLECTED, not posted: nothing is announced until the whole picture is known', () => { + // The MCP part of the picture is not known until setupMcp has run, so posting rules and memory + // before it would make the decision on incomplete information. + assert.match(agent, /const contextChips = \[\]/, 'the context chips must be collected'); + assert.match(agent, /contextChips\.push\(\{ type: 'agentTool', icon: 'file', text: '📋 project rules/, + 'rules must be collected, not posted directly'); + assert.match(agent, /contextChips\.push\(\{ type: 'agentTool', icon: 'history', text: '🧠 project memory/, + 'memory must be collected, not posted directly'); + assert.match(agent, /if \(mcp\.announce\) \{ contextChips\.push\(mcp\.announce\); \}/, + 'the MCP line must join the same decision'); + assert.match(agent, /built\.announce = \{/, 'setupMcp must hand its line back rather than post it'); +}); + +test('SIGNATURE, not a flag: the announcement returns the moment anything moves', () => { + // A boolean would go quiet forever after the first run — a server dropping out, a rules file + // appearing, or memory arriving for the first time would all pass unmentioned. + assert.match(agent, /let lastContextSig = ''/, 'the memo must hold a signature'); + assert.match(agent, /const sig = contextChips\.map\(\(c\) => c\.text\)\.join\('\|'\)/, + 'the signature must be built from what would actually be shown'); + assert.match(agent, /if \(sig && sig !== lastContextSig\)/, 'and compared before announcing'); + assert.ok(!/let announcedContext = (true|false)/.test(agent), 'a boolean memo is the bug this avoids'); +}); + +test('FAILURES are news every time', () => { + // A server that broke must not be suppressed by a signature that happens to match. + const setup = agent.slice(agent.indexOf('async function setupMcp'), agent.indexOf('async function runAgent')); + for (const failing of ['setup failed', 'failed to start']) { + const at = setup.indexOf(failing); + assert.ok(at > 0, 'expected a failure path mentioning: ' + failing); + const line = setup.slice(setup.lastIndexOf('\n', at), setup.indexOf('\n', at)); + assert.match(line, /ctx\.post\(/, 'a failure must post immediately, not be collected: ' + failing); + } +}); + +test('A NEW CHAT hears it again', () => { + // The suppression is about repetition WITHIN a conversation. A fresh conversation is a fresh + // slate and the context is news again. + assert.match(agent, /function resetContextAnnounce\(\) \{ lastContextSig = ''; \}/, 'no reset'); + assert.match(agent, /module\.exports = \{ resetContextAnnounce,/, 'the reset must be exported'); + assert.match(ext, /const \{ runAgent, resetContextAnnounce \} = require\('\.\/agent'\)/, 'and imported'); + const reset = ext.slice(ext.indexOf('function resetConversationState'), ext.indexOf('function resetConversationState') + 900); + assert.match(reset, /resetContextAnnounce\(\)/, 'a conversation teardown must clear the memo'); +}); + +test('EMPTY context does not pin the memo shut', () => { + // A run with no rules, no memory and no MCP must not leave a stale signature that suppresses the + // announcement once those things DO appear. + assert.match(agent, /\} else if \(!sig\) \{[\s\S]{0,120}lastContextSig = ''/, + 'an empty picture must clear the memo, not keep the last one'); +}); + +console.log('\ncontextAnnounce: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/groupReducer.test.js b/extensions/levelcode-ai/test/groupReducer.test.js index 4843999..b8cdf86 100644 --- a/extensions/levelcode-ai/test/groupReducer.test.js +++ b/extensions/levelcode-ai/test/groupReducer.test.js @@ -101,7 +101,7 @@ function newHarness(groupsOn) { 'collapseMember', 'groupAppend', 'refreshGroupHead', 'finalizeGroup', 'closeGroup', 'groupStepDone', 'groupStepCounts', 'addAgentLine', 'add' ].map(extract).join('\n') - + '\nthis.api = { openGroup, groupAppend, closeGroup, groupStepDone, groupStepCounts, addAgentLine, add, get curGroup(){ return curGroup; } };'; + + '\nthis.api = { openGroup, groupAppend, closeGroup, groupStepDone, groupStepCounts, addAgentLine, add, groupAggregate, chipStep, get curGroup(){ return curGroup; } };'; new Function('document', 'log', src).call(sandbox, { createElement: (t) => new El(t) }, log); return { log, api: /** @type {any} */ (sandbox).api }; } @@ -317,4 +317,83 @@ test('a new user message re-arms the label', () => { assert.strictEqual(roleOf(h.log.children[4]), 'LevelCode AI', 'the next turn is labelled again'); }); + +// ── the collapsed header must SAY something (the "3 steps" problem) ───────────────────────────── + +test('SETUP STEPS: a run that only loaded context says so, instead of counting', () => { + // This was the reported UX gap: rules + memory + MCP summarised as "3 steps", which tells nobody + // anything. They fell through chipStep into `note`, which contributes no phrase. + const S = newHarness().api; + const chip = (icon, text) => S.chipStep(icon, text, '', undefined, undefined); + const steps = [ + chip('file', '📋 project rules · CLAUDE.md'), + chip('history', '🧠 project memory'), + chip('sparkle', '🔌 mcp · github (26) · 2/26 allow-listed') + ]; + const out = S.groupAggregate(steps); + assert.ok(!/^\d+ steps$/.test(out), 'must not fall back to a bare count: ' + out); + assert.match(out, /project rules/, 'should name the rules it loaded'); + assert.match(out, /memory/, 'and the memory'); + assert.match(out, /MCP/, 'and the MCP connection'); +}); + +test('SETUP STEPS: each kind is recognised, not lumped into note', () => { + const S = newHarness().api; + const chip = (icon, text) => S.chipStep(icon, text, '', undefined, undefined); + const kindOf = (icon, text) => chip(icon, text).kind; + assert.strictEqual(kindOf('file', '📋 project rules · CLAUDE.md'), 'rules'); + assert.strictEqual(kindOf('history', '🧠 project memory'), 'memory'); + assert.strictEqual(kindOf('history', '🧠 recalling: how auth works'), 'recall'); + assert.strictEqual(kindOf('sparkle', '🧩 using skill: pdf'), 'skill'); + assert.strictEqual(kindOf('globe', '🌐 preview · http://localhost:3000'), 'preview'); + // A tool CALL through a server is different work from setup chatter about servers. + assert.strictEqual(kindOf('sparkle', '🔌 github · search_code'), 'mcpcall'); + assert.strictEqual(kindOf('sparkle', '🔌 mcp · github (26) · 2/26 allow-listed'), 'mcp'); +}); + +test('SETUP STEPS: real work still leads the sentence', () => { + // Setup is named, but never at the expense of what actually changed. + const S = newHarness().api; + const chip = (icon, text) => S.chipStep(icon, text, '', undefined, undefined); + const out = S.groupAggregate([ + chip('file', '📋 project rules · CLAUDE.md'), + { kind: 'cmd' }, + { kind: 'edit', path: 'src/a.ts' } + ]); + assert.ok(out.indexOf('a.ts') < out.indexOf('project rules'), + 'what changed must come before what was loaded: ' + out); +}); + +test('FALLBACK: an unrecognised step is named, not counted', () => { + const S = newHarness().api; + const out = S.groupAggregate([ + S.chipStep('info', 'something unusual happened', '', undefined, undefined), + S.chipStep('info', 'and another', '', undefined, undefined) + ]); + assert.match(out, /^Something unusual happened and 1 more$/, + 'name the first and count the rest, sentence-cased like every other path: ' + out); +}); + + +test('CHEVRON: the disclosure control TRAILS what it discloses', () => { + // "Ran 2 commands ⌄", not "⌄ Ran 2 commands" — matching the reference. Pinned on DOM order, not + // on CSS `order`, so the tab order and the visual order stay the same thing. + const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8'); + + const head = html.slice(html.indexOf("'
= 0, 'no function ' + name); + const open = src.indexOf('{', i); + let depth = 0; + for (let j = open; j < src.length; j++) { + if (src[j] === '{') { depth++; } + else if (src[j] === '}') { depth--; if (!depth) { return src.slice(open, j + 1); } } + } + assert.fail('unbalanced braces in ' + name); +} + +test('CAP: the long edge cap is 2000 — the value Claude Code ships, not a guess', () => { + // Measured from Claude Code's own transcripts: every image it re-encodes is 2000 on the long + // edge. It is also the threshold the vision docs name for staying clear of the stricter + // per-image dimension limit above 20 images per request. + const m = /const IMG_CAP = (\d+);/.exec(html); + assert.ok(m, 'IMG_CAP is gone'); + assert.strictEqual(m[1], '2000'); +}); + +test('NEVER UPSCALE, and never re-encode what did not need resizing', () => { + const body = fnBody(html, 'normalizeImage'); + assert.match(body, /Math\.min\(1,\s*IMG_CAP\s*\/\s*Math\.max\(w,\s*h\)\)/, + 'the scale must be clamped at 1 — upscaling costs bytes and tokens and adds nothing'); + assert.match(body, /if \(scale === 1\)/, 'there must be a pass-through branch'); + // The pass-through must forward the ORIGINAL file, in its original type. + const passthrough = body.slice(body.indexOf('if (scale === 1)'), body.indexOf('const tw =')); + assert.match(passthrough, /media_type:\s*file\.type/, 'pass-through must keep the source format'); + assert.match(passthrough, /blobToBase64\(file\)/, 'pass-through must forward the ORIGINAL bytes'); + assert.ok(!/canvas|drawImage|convertToBlob/i.test(passthrough), + 're-encoding an image that did not need resizing only stacks compression artifacts'); +}); + +test('FORMATS: only what Claude accepts, and the webview and the store agree', () => { + const inWebview = (/const IMG_OK = \{([^}]*)\}/.exec(html) || [, ''])[1]; + for (const t of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) { + assert.ok(inWebview.includes(t), 'webview should accept ' + t); + } + assert.ok(!/image\/svg|image\/tiff|image\/bmp/.test(inWebview), 'only the four Claude reads'); + const store = fs.readFileSync(path.join(__dirname, '..', 'imageStore.js'), 'utf8'); + for (const t of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) { + assert.ok(store.includes("'" + t + "'"), 'store should accept ' + t); + } +}); + +test('PASTE: a text paste is untouched; only an image paste is intercepted', () => { + const i = html.indexOf("input.addEventListener('paste'"); + assert.ok(i > 0, 'no paste handler'); + const body = html.slice(i, i + 900); + assert.match(body, /if \(!imgs\.length\) \{ return; \}/, + 'a paste with no image must fall through to normal text pasting'); + assert.ok(body.indexOf('if (!imgs.length) { return; }') < body.indexOf('preventDefault'), + 'preventDefault must come AFTER the no-image bail, or plain text pasting breaks'); +}); + +test('REFUSAL: a blind model refuses BEFORE anything typed is lost', () => { + // The composer clears on send, so refusing host-side would throw away what they wrote. The + // capability rides with the model instead, and the refusal happens at attach time. + const body = fnBody(html, 'attachImageFiles'); + assert.match(body, /if \(!canSeeImages\)/, 'no vision gate at attach time'); + assert.ok(body.indexOf('if (!canSeeImages)') < body.indexOf('normalizeImage'), + 'refuse before doing the decode work, not after'); + assert.match(body, /cannot read images/, 'the refusal must say what is wrong'); + assert.match(body, /Switch to a vision model/, '…and what to do about it'); + assert.match(ext, /canSeeImages: supportsVisionForModel\(/, 'the host must publish the capability'); + assert.strictEqual((ext.match(/canSeeImages: supportsVisionForModel\(/g) || []).length, 2, + 'both config paths — gateway and BYOK — must publish it, or one of them silently allows'); +}); + +test('BUDGET: a per-turn image count, enforced where images are added', () => { + const body = fnBody(html, 'attachImageFiles'); + assert.match(body, /IMG_MAX_PER_TURN/, 'no per-turn cap'); + assert.match(body, /break;/, 'the cap must stop the loop, not just warn'); + assert.match(body, /were not attached/, 'silently dropping attachments is the bug pattern'); + assert.match(body, /remove one to add another/, 'and it must say how to make room'); +}); + +test('SEND: an image with no words is a valid message', () => { + const body = fnBody(html, 'doSend'); + assert.match(body, /if \(!t && !pendingImages\.length\) return;/, + '"look at this" is implied by attaching — an image alone must be sendable'); + assert.match(body, /images: imgs/, 'the send payload must carry the images'); + assert.match(body, /clearImages\(\)/, 'the tray must empty on send, or the next turn re-sends them'); +}); + +test('HOST: images become refs in the conversation, and bytes only at request time', () => { + assert.match(ext, /case 'send': await handleSend\(msg\.text, msg\.images\)/, 'send must carry images'); + const store = fnBody(ext, 'storeImages'); + assert.match(store, /imageStore\.put\(/, 'bytes must go through the store'); + assert.match(store, /type: 'image', ref/, 'the conversation must keep a ref, never the base64'); + assert.ok(!/base64/.test(store.replace(/im\.base64/g, '')), 'no base64 should be retained host-side'); + + // A copy, not a mutation: agentMessages persists across runs and is what recordTurn writes. + const w = fnBody(ext, 'withImages'); + assert.match(w, /msgs\.map\(/, 'withImages must map to a new array'); + assert.match(w, /\{ \.\.\.msg, content:/, 'and copy each touched message rather than mutating it'); + assert.ok(!/msg\.content\[|\.content\s*=/.test(w), 'must not write into the caller\'s messages'); + for (const site of ['withImages(conversation)', 'withImages(agentMessages)']) { + assert.ok(ext.includes(site), 'the request must materialize at: ' + site); + } +}); + +test('CONVERSATION: blocks only when there is an image', () => { + const body = fnBody(ext, 'handleSend'); + assert.match(body, /\[\.\.\.labelImages\(imageBlocks\), \{ type: 'text', text: userContent \}\]/, + 'images lead the block array'); + assert.match(body, /:\s*\{ role: 'user', content: userContent \}/, + 'a text-only turn must stay a plain string, or every cached prefix churns'); +}); + +test('AGENT MODE: the default path carries images too', () => { + // I shipped this broken. handleSend stored the bytes and then early-returned into agentFlow(text), + // which never saw them — so in the DEFAULT mode a pasted screenshot was written to disk and + // dropped. The exact silent-drop failure this whole feature exists to prevent. + assert.match(ext, /if \(agentMode\) \{ await agentFlow\(text, imageBlocks\); return; \}/, + 'agent mode must forward the image blocks'); + assert.match(ext, /async function agentFlow\(text, imageBlocks\)/, 'agentFlow must accept them'); + const body = fnBody(ext, 'agentFlow'); + assert.match(body, /imageBlocks && imageBlocks\.length/, 'and use them when present'); + assert.match(body, /\[\.\.\.labelImages\(imageBlocks\), \{ type: 'text', text \}\]/, 'images lead the goal'); + assert.match(body, /:\s*\{ role: 'user', content: text \}/, + 'a text-only goal must stay a plain string, or every cached agent prefix churns'); +}); + +test('NO WORKSPACE: an image still has somewhere to live', () => { + // v1.1.0 deliberately made the agent answer with no folder open. Refusing a screenshot in that + // state would re-introduce the limitation that release removed. + const body = fnBody(ext, 'imageRoot'); + assert.match(body, /sessionsManager\(\)/, 'prefer the project session dir when there is one'); + assert.match(body, /_no-workspace/, 'and fall back when there is not'); + assert.ok(!/Images need a session/.test(ext), 'the old session-required refusal must be gone'); +}); + +test('400: an image with no words must not emit an empty text block', () => { + // Anthropic rejects it outright — "text content blocks must be non-empty" — and an image sent + // with no words produced exactly that. Both paths must omit the block rather than send "". + const send = fnBody(ext, 'handleSend'); + assert.match(send, /userContent \? \[\.\.\.labelImages\(imageBlocks\)/, 'handleSend must gate the text block on there being text'); + assert.match(send, /:\s*labelImages\(imageBlocks\)/, 'handleSend must fall back to the images alone'); + + const agent = fnBody(ext, 'agentFlow'); + assert.match(agent, /text \? \[\.\.\.labelImages\(imageBlocks\)/, 'agentFlow must gate the text block on there being text'); + assert.match(agent, /:\s*labelImages\(imageBlocks\)/, 'agentFlow must fall back to the images alone'); +}); + +test('CSP: the webview is allowed to render a data: image, and nothing else', () => { + // default-src 'none' blocks every image, which is why the first thumbnail rendered broken. + const csp = fnBody(ext, 'webviewCsp'); + assert.match(csp, /"img-src data:"/, 'attached images cannot render without this'); + assert.ok(!/img-src[^"]*https:/.test(csp), 'the panel must not be able to fetch a remote image'); +}); + +test('PICKER + DROP: a Finder file reaches the same normalizer as a paste', () => { + // VS Code's workbench intercepts OS file drops before a webview iframe sees them, so + // dataTransfer.files is usually empty for a Finder drag while the PATH is still there. + assert.match(html, /getData\('text\/uri-list'\)/, 'no uri-list fallback for the VS Code drop case'); + assert.match(html, /type: 'attachImagePaths'/, 'paths must be handed to the host to read'); + assert.match(ext, /case 'attachImagePaths'/, 'the host must accept them'); + assert.match(ext, /case 'pickImages'/, 'and offer a picker that works regardless'); + assert.match(ext, /showOpenDialog\(/, 'the picker must be a real file dialog'); + + const reader = fnBody(ext, 'attachImagePaths'); + assert.match(reader, /25 \* 1024 \* 1024/, 'guard the size BEFORE base64 crosses the message bus'); + assert.match(reader, /is not an image LevelCode can read/, 'a non-image must say so, not fail silently'); + assert.match(html, /function b64ToFile/, 'host bytes must become a File so both routes share normalizeImage'); + assert.match(html, /id="attachImg"/, 'there must be a visible way in besides paste'); +}); + +test('FINDER DROP: the workbench wins, so the recovery is one click from where it lands', () => { + // VS Code's workbench takes an OS file drop and OPENS the file before a webview iframe sees any + // event — the drop handler inside the panel never fires, and the uri-list fallback with it. The + // image the user meant to attach is therefore sitting in a tab. Offer it there. + const pick = fnBody(ext, 'pickImages'); + assert.match(pick, /openImageTabs\(\)/, 'the picker must offer images already open in tabs'); + assert.match(pick, /\(b\.active \? 1 : 0\) - \(a\.active \? 1 : 0\)/, + 'the active tab is the one they just dropped — it must come first'); + assert.match(pick, /showOpenDialog\(/, 'and still fall through to a real file dialog'); + + const tabs = fnBody(ext, 'openImageTabs'); + assert.match(tabs, /uri\.scheme !== 'file'/, 'only real files on disk can be read'); + assert.match(tabs, /IMAGE_EXTS\.test/, 'and only images'); + assert.match(tabs, /seen\.has\(uri\.fsPath\)/, 'the same file open in two groups must appear once'); + + // A button on the image tab's own title bar: that is where the drop lands. + const pkg = require('../package.json'); + const menu = pkg.contributes.menus['editor/title'] + .find((m) => m.command === 'levelcode.ai.attachImage'); + assert.ok(menu, 'no attach button on the image editor title bar'); + assert.match(menu.when, /png|jpe\?g/, 'it must only appear for images'); + assert.match(menu.group, /^navigation/, 'in navigation, or it hides under the overflow menu'); + assert.ok(pkg.contributes.commands.some((c) => c.command === 'levelcode.ai.attachImage' && c.icon), + 'the command needs an icon to render as a button'); +}); + +test('REMOVE: the image × is not stolen by the generic chip handler', () => { + // The image × carries BOTH `x` and `imgx`, and both handlers assign .onclick rather than adding a + // listener — so whichever registers last silently wins. It did: clicking × posted removeContext + // with a null id and the image stayed attached. Excluded by selector, not by ordering, so it + // cannot come back the next time these two blocks move relative to each other. + const body = fnBody(html, 'renderChips'); + assert.match(body, /querySelectorAll\('\.x:not\(\.imgx\)'\)/, + 'the generic chip handler must exclude image chips, or it overwrites the remove handler'); + assert.match(body, /querySelectorAll\('\.imgx'\)/, 'image chips need their own handler'); + assert.match(body, /removeImage\(/, '…which must actually remove the image'); + + // Focusable by keyboard, so it must be activatable by keyboard — reachable-but-dead is worse + // than not reachable. + assert.match(body, /onkeydown/, 'the × is role=button tabindex=0 and needs a key handler'); + assert.match(body, /'Enter' \|\| e\.key === ' '/, 'Enter and Space both activate a button'); + + const rm = fnBody(html, 'removeImage'); + assert.match(rm, /pendingImages\.filter/, 'remove must drop it from the pending list'); + assert.match(rm, /renderChips\(\)/, 'and re-render, or the chip stays on screen'); +}); + +test('CAP: one setting, honoured on every route in, and visible before it bites', () => { + // Each image is ~1,800-3,000 input tokens, so a maxed message is a five-figure prompt before a + // word is typed. The number must be the SAME everywhere or one route quietly allows more. + const pkg = require('../package.json'); + const setting = pkg.contributes.configuration.properties['levelcode.ai.chat.maxImagesPerMessage']; + assert.ok(setting, 'no setting for the image cap'); + assert.strictEqual(setting.default, 5); + + // Clamped in code, not just in the settings editor — a hand-edited settings.json is unchecked. + const clamp = fnBody(ext, 'maxImagesPerMessage'); + assert.match(clamp, /n < 1/, 'a zero or negative cap must not disable attaching entirely'); + assert.match(clamp, /Math\.min\(Math\.floor\(n\), 20\)/, 'and it must be bounded above'); + + // Every route in uses it: the host path (drop + picker) and the webview path (paste + drop). + assert.match(ext, /\.slice\(0, maxImagesPerMessage\(\)\)/, 'the host path must use the setting'); + assert.ok(!/slice\(0, 8\)/.test(ext), 'a hardcoded cap must not survive beside the setting'); + assert.strictEqual((ext.match(/maxImages: maxImagesPerMessage\(\)/g) || []).length, 2, + 'both config paths must publish it, or one silently keeps the old default'); + assert.match(html, /pendingImages\.length >= IMG_MAX_PER_TURN/, + 'the webview cap must be CUMULATIVE, not per-batch'); + assert.match(fnBody(html, 'syncAttachImgBtn'), /Image limit reached/, + 'the cap should be visible on the button before it refuses anything'); +}); + +test('UX: an attachment problem is reported where it happened, not in a corner toast', () => { + // This used to post to the host and surface as a VS Code notification in the far corner of the + // window — seconds later, a long way from the paste, and outliving the moment it described. + const body = fnBody(html, 'note'); + assert.match(body, /getElementById\('imgnote'\)/, 'the notice must render in the composer'); + assert.ok(!/postMessage/.test(body), 'it must no longer be thrown to a global notification'); + assert.match(body, /setTimeout/, 'and it must clear itself rather than linger over the next try'); + assert.match(html, /id="imgnote"/, 'the notice element is missing from the composer'); + assert.match(html, /aria-live="polite"/, 'a screen reader must hear it too'); +}); + +test('UX: a chip appears the instant you paste, before the decode finishes', () => { + // Decoding and re-encoding a 4K screenshot takes long enough to read as "nothing happened". + const body = fnBody(html, 'attachImageFiles'); + assert.match(body, /loading: true/, 'no placeholder chip while the image is being prepared'); + assert.ok(body.indexOf('loading: true') < body.indexOf('await normalizeImage'), + 'the placeholder must go in BEFORE the work, or it is not feedback'); + assert.match(body, /at < 0.*continue|if \(at < 0\)/s, + 'an image removed mid-decode must stay removed, not reappear when its bytes arrive'); + assert.match(body, /pendingImages\.filter/, 'a failed image must not leave its placeholder behind'); + assert.match(fnBody(html, 'imgChip'), /im\.loading/, 'the chip must render the loading state'); +}); + +test('UX: the chip says what the image will cost, compactly', () => { + // This product meters credits per turn and an image is not a rounding error. Someone deciding + // whether to attach three should see that before they send, not after they are billed. + const chip = fnBody(html, 'imgChip'); + assert.match(chip, /imgTokens\(im\.w, im\.h\)/, 'the chip must compute a real token cost'); + assert.match(chip, /toFixed\(1\) \+ 'k'/, 'compact on the chip — full width wraps the tray'); + assert.match(chip, /input tokens/, 'the exact figure belongs in the tooltip'); + + const t = fnBody(html, 'imgTokens'); + assert.match(t, /Math\.ceil\(w \/ 28\) \* Math\.ceil\(h \/ 28\)/, 'must be the real 28px patch formula'); + assert.match(t, /4784/, 'and clamped to the tier ceiling the server enforces anyway'); +}); + +test('UX: a thumbnail can be opened full size, and closed again', () => { + // A 28px thumb cannot tell you WHICH screenshot you attached — the one thing worth checking + // before sending. + assert.match(html, /id="imgzoom"/, 'no full-size view'); + assert.match(html, /aria-modal="true"/, 'the overlay should announce itself as a dialog'); + const z = fnBody(html, 'closeZoom'); + assert.match(z, /src = ''/, 'closing must drop the src, or the bytes stay live in the DOM'); + assert.match(html, /e\.key === 'Escape'/, 'Escape must close it'); + assert.match(html, /classList\.contains\('msgimg'\)/, 'transcript images must open too, not just the tray'); +}); + +// ── review of #90 ─────────────────────────────────────────────────────────────────────────────── + +test('METER: compaction costs images at the ACTIVE model tier, not the default', () => { + // estimateMsgTokens takes a modelId to pick the resolution tier — 4,784 visual tokens on high-res + // against 1,568 on standard. Both compaction call sites omitted it, so every screenshot was + // costed at a third of what a Claude 4.7+ model is actually charged, contradicting the module's + // own "never under-count" rule. + assert.strictEqual((ext.match(/estimateMsgTokens\(msgs, meterModel\(\)\)/g) || []).length, 2, + 'both before/after call sites must pass the active model'); + assert.ok(!/estimateMsgTokens\(msgs\)\s*;/.test(ext), 'no bare call may survive'); + const m = fnBody(ext, 'meterModel'); + assert.match(m, /gateway/, 'the gateway model must resolve like currentContextLimit does'); + assert.match(m, /activeModel\(cfg, currentProviderId\(\)\)/, '…and BYOK the same way'); +}); + +test('CAP: the not-attached count is taken from the batch, and can never go negative', () => { + // It subtracted the whole tray from the batch size: four attached, cap five, drop two, and the + // message claimed "-3 not attached". + const body = fnBody(html, 'attachImageFiles'); + assert.match(body, /const dropped = list\.length - fi/, 'count from the position in THIS batch'); + assert.ok(!/list\.length - pendingImages\.length/.test(body), + 'subtracting the tray from the batch is what goes negative'); +}); + +test('SEND: never sends while an image is still decoding', () => { + // A placeholder chip carries no bytes. Sending mid-decode posts undefined media_type/base64, + // the host refuses it, and the image vanishes from a message the user watched it attach to. + assert.match(html, /const inflightImages = new Set\(\)/, 'in-flight work must be tracked'); + const body = fnBody(html, 'doSend'); + // The NAME appearing is not enough — it appears in the wait loop too, so `if (false)` around the + // gate left this passing. Assert the gate itself, and that it runs before anything is posted. + assert.match(body, /if \(inflightImages\.size\) \{/, 'the in-flight check must be a real gate'); + assert.match(body, /await new Promise/, 'and it must actually wait'); + const gateAt = body.indexOf('if (inflightImages.size) {'); + const sendAt = body.indexOf("vscode.postMessage({ type: 'send'"); + assert.ok(gateAt >= 0 && sendAt > gateAt, 'the wait must come BEFORE the send'); + assert.match(body, /i\.loading/, 'and a surviving placeholder is refused outright'); + assert.match(html, /async function doSend/, 'waiting requires it to be async'); + assert.match(fnBody(html, 'attachImageFiles'), /finally \{\s*inflightImages\.delete/, + 'a failed decode must not leave the tray permanently "busy"'); +}); + +test('SEND: the model is re-checked, because it can change after attaching', () => { + // The attach-time gate is not enough — a model can be switched between attaching and sending. + const body = fnBody(html, 'doSend'); + assert.match(body, /pendingImages\.length && !canSeeImages/, 'no re-check before sending'); + assert.ok(body.indexOf('!canSeeImages') < body.indexOf('vscode.postMessage({ type: \'send\''), + 'the check must come BEFORE the send'); + assert.ok(!/clearImages\(\)/.test(body.slice(body.indexOf('!canSeeImages'), body.indexOf('return;', body.indexOf('!canSeeImages')))), + 'refusing must not discard what was typed or attached'); +}); + +test('NEW CHAT does not inherit the previous conversation attachments', () => { + // Comments stripped first: a commented-out call still satisfies a bare /resetImages\(\)/, which is + // exactly how disabling it left this test green. + const live = html.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const at = live.indexOf("m.type === 'reset'"); + assert.ok(at > 0, 'the reset handler is gone'); + const reset = live.slice(at, at + 400); + assert.match(reset, /resetImages\(\)/, 'reset must clear the image tray'); + const r = fnBody(html, 'resetImages'); + assert.match(r, /clearImages\(\)/, 'and the pending list with it'); + assert.match(r, /renderChips\(\)/, 'and re-render, or the chips stay on screen'); + assert.match(fnBody(html, 'clearImages'), /inflightImages\.clear\(\)/, + 'in-flight work from the old conversation must not land in the new one'); +}); + +test('VISION GATE: the provider and the model must BOTH allow it', () => { + // The registry enumerates vision providers; `custom` is an arbitrary user endpoint and declares + // none. Reading only the model id would hand images to it whenever the model NAME looked right. + const cat = require('../providers/catalog'); + assert.strictEqual(cat.supportsVisionForModel('custom', 'gpt-4o'), false, 'custom must not opt in by model name'); + assert.strictEqual(cat.supportsVisionForModel('ollama', 'llava'), false); + assert.strictEqual(cat.supportsVisionForModel('anthropic', 'claude-opus-4-8'), true); + assert.strictEqual(cat.supportsVisionForModel('openai', 'gpt-4o'), true); + assert.strictEqual(cat.supportsVisionForModel('anthropic', 'not-a-real-model'), false); +}); + +test('TRANSLATE: the assistant loop fails loudly too', () => { + // I1 fixed the user loop and left the assistant one dropping unknown blocks silently — the same + // bug, one branch over. + const T = require('../providers/translate'); + assert.throws(() => T.toOpenAIMessages('', [{ role: 'assistant', content: [{ type: 'video' }] }]), + /unsupported content block in an assistant message/); + // Thinking is a DELIBERATE drop, not a loss: an OpenAI-shaped request has nowhere to put it. + assert.doesNotThrow(() => T.toOpenAIMessages('', [{ role: 'assistant', content: [ + { type: 'thinking', thinking: 'x' }, { type: 'text', text: 'hi' }] }])); +}); + +test('MEDIA: there is a real sweep, and the comment no longer claims one it does not have', () => { + // Sessions are append-only and trash() only writes a lifecycle event, so "deleted with them" was + // simply untrue, and refsIn had no production caller at all. + const store = fs.readFileSync(path.join(__dirname, '..', 'imageStore.js'), 'utf8'); + const sessions = fs.readFileSync(path.join(__dirname, '..', 'sessions.js'), 'utf8'); + assert.match(store, /function sweep\(root, slug, keep, maxAgeMs\)/, 'no sweep'); + assert.match(store, /if \(!isRef\(name\)\) \{ continue; \}/, 'a sweep must never touch a file we did not write'); + assert.match(store, /st\.mtimeMs > cutoff/, + 'an age floor is required — a normal chat writes media whose refs are never persisted'); + assert.match(sessions, /function sweepMedia/, 'the sessions manager must expose it'); + assert.match(sessions, /imageStore\.refsIn/, 'refsIn must finally have a caller'); + assert.ok(!/deleted with them/.test(sessions), 'the false retention claim must be gone'); + assert.match(ext, /sweepMedia\(\)/, 'and something must actually call it'); +}); + +test('MULTI-IMAGE: several images are introduced by name', () => { + // From the vision guidance: with several images, precede each with "Image 1:" so the question — + // and every follow-up turn — can refer to them. + const body = fnBody(ext, 'labelImages'); + assert.match(body, /blocks\.length < 2/, 'a single image needs no label'); + assert.match(body, /'Image ' \+ \(i \+ 1\) \+ ':'/, 'the labels must be the documented shape'); + assert.strictEqual((ext.match(/labelImages\(imageBlocks\)/g) || []).length, 4, + 'both paths, both with and without text, must label'); +}); + +console.log('\nimageAttach: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/imageCost.test.js b/extensions/levelcode-ai/test/imageCost.test.js new file mode 100644 index 0000000..d2d96db --- /dev/null +++ b/extensions/levelcode-ai/test/imageCost.test.js @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Image geometry + cost — run: node test/imageCost.test.js + * + * The load-bearing test here is DOC PARITY: every worked example in the vision documentation, + * reproduced. If these drift, the context meter is lying and the UI is showing the user a size + * the server will not actually use. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const C = require('../imageCost'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +test('PATCHES: cost is a ceiling division on each axis, independently', () => { + assert.strictEqual(C.visualTokens(28, 28), 1); + assert.strictEqual(C.visualTokens(29, 28), 2, 'one pixel over buys a whole column of patches'); + assert.strictEqual(C.visualTokens(28, 29), 2, '…and a whole row'); + assert.strictEqual(C.visualTokens(1092, 1092), 1521); + assert.strictEqual(C.visualTokens(0, 100), 0); +}); + +test('DOC PARITY: every worked example in the vision docs, both tiers', () => { + // [w, h, standard "WxH/tokens", high-res "WxH/tokens"] + const rows = [ + [1092, 1092, '1092x1092/1521', '1092x1092/1521'], + [1000, 1000, '1000x1000/1296', '1000x1000/1296'], + [1920, 1080, '1456x819/1560', '1920x1080/2691'], + [3840, 2160, '1456x819/1560', '2576x1449/4784'], + [200, 200, '200x200/64', '200x200/64'] + ]; + for (const [w, h, std, hi] of rows) { + const a = C.fitToTier(w, h, 'standard'), b = C.fitToTier(w, h, 'high'); + assert.strictEqual(`${a.w}x${a.h}/${a.tokens}`, std, `standard tier, ${w}x${h}`); + assert.strictEqual(`${b.w}x${b.h}/${b.tokens}`, hi, `high-res tier, ${w}x${h}`); + } +}); + +test('CAPS: nothing escapes its tier, at any source size', () => { + for (const [w, h] of [[8000, 8000], [8000, 200], [200, 8000], [4032, 3024], [3024, 1964]]) { + for (const tier of ['standard', 'high']) { + const r = C.fitToTier(w, h, tier); + assert.ok(r.tokens <= C.TIERS[tier].tokens, `${w}x${h} ${tier}: ${r.tokens} over the token cap`); + assert.ok(Math.max(r.w, r.h) <= C.TIERS[tier].edge, `${w}x${h} ${tier}: over the long-edge cap`); + } + } +}); + +test('NEVER UPSCALE: a small source comes back untouched', () => { + // Writing the plan, `sips -Z 1568` GREW a 1160x480 capture from 40KB to 89KB by scaling it up + // to meet the cap. Upscaling costs bytes and tokens and adds no information. + for (const [w, h] of [[100, 50], [1160, 480], [1568, 1018], [2576, 1449]]) { + const hi = C.fitToTier(w, h, 'high'); + assert.ok(hi.w <= w && hi.h <= h, `${w}x${h} was scaled UP to ${hi.w}x${hi.h}`); + assert.strictEqual(C.clientScale(w, h, 4000), 1, 'a cap above the source must be a no-op'); + const t = C.clientTarget(w, h, 4000); + assert.deepStrictEqual([t.w, t.h, t.scaled], [w, h, false]); + } +}); + +test('ASPECT: downscaling preserves the ratio to within a pixel', () => { + for (const [w, h] of [[3840, 2160], [3024, 1964], [4032, 3024], [1920, 1200]]) { + const r = C.fitToTier(w, h, 'high'); + assert.ok(Math.abs((r.w / r.h) - (w / h)) < 0.01, `${w}x${h} -> ${r.w}x${r.h} skewed the aspect`); + } +}); + +test('CLIENT CAP: scale is a no-op at 1, so the caller can skip re-encoding', () => { + // A factor of exactly 1 is the signal to forward the ORIGINAL bytes. Re-encoding an untouched + // image only stacks compression artifacts, worst on the screenshots of text people paste. + assert.strictEqual(C.clientScale(1000, 800, 1568), 1); + assert.strictEqual(C.clientTarget(1000, 800, 1568).scaled, false); + const t = C.clientTarget(3840, 2160, 1568); + assert.deepStrictEqual([t.w, t.h, t.scaled], [1568, 882, true]); + assert.strictEqual(C.clientScale(3840, 2160, 0), 1, 'cap 0 means no cap, not a zero-size image'); +}); + +test('TIER: 4.7-and-later is high-res; anything unrecognised is standard, never the reverse', () => { + for (const id of ['claude-opus-5', 'claude-sonnet-5', 'anthropic/claude-opus-4-8', 'claude-fable-5']) { + assert.strictEqual(C.tierFor(id), 'high', id); + } + for (const id of ['claude-opus-4-6', 'gpt-4o', 'some-unknown-model', '', null]) { + assert.strictEqual(C.tierFor(id), 'standard', String(id)); + } +}); + +test('METER: an image block costs its real visual tokens, not its JSON length', () => { + // The bug this replaces: estimateMsgTokens is JSON.stringify(m).length / 4, which charges a + // base64 image about a third of its BYTE count — a 1MB screenshot books ~333,000 phantom + // tokens, more than most context windows, for something that really costs ~4,800. That + // misreports the UI meter today; it would be a correctness bug under any auto-compaction. + const block = { type: 'image', ref: 'a'.repeat(64), w: 3840, h: 2160, media_type: 'image/png' }; + assert.strictEqual(C.imageBlockTokens(block, 'claude-opus-5'), 4784); + assert.strictEqual(C.imageBlockTokens(block, 'gpt-4o'), 1560, 'standard tier costs less'); + + const naive = Math.round(JSON.stringify(block).length / 4); + assert.ok(C.imageBlockTokens(block, 'claude-opus-5') > naive * 10, + 'a ref is ~18 tokens by JSON length and ~4784 in reality — the meter must not under-count either'); + + assert.strictEqual(C.imageBlockTokens({ type: 'text', text: 'hi' }, 'claude-opus-5'), 0); + assert.strictEqual(C.imageBlockTokens(null, 'claude-opus-5'), 0); +}); + +test('METER: an image of unknown size is charged the cap, never zero', () => { + // A missing dimension must fail toward over-counting. Charging zero would let a conversation + // full of images look empty to the compaction cut. + assert.strictEqual(C.imageBlockTokens({ type: 'image', ref: 'x' }, 'claude-opus-5'), 4784); + assert.strictEqual(C.imageBlockTokens({ type: 'image', ref: 'x', w: 100 }, 'gpt-4o'), 1568); +}); + +console.log('\nimageCost: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/imageStore.test.js b/extensions/levelcode-ai/test/imageStore.test.js new file mode 100644 index 0000000..cb7b0c6 --- /dev/null +++ b/extensions/levelcode-ai/test/imageStore.test.js @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Local image store + the meter that counts what it holds — run: node test/imageStore.test.js + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const S = require('../imageStore'); +const { estimateMsgTokens } = require('../agentMemory'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-img-')); +const slug = 'proj'; +const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex').toString('base64'); + +test('ROUND TRIP: bytes go in, the same bytes come back', () => { + const { ref, bytes } = S.put(root, slug, png, 'image/png'); + assert.ok(S.isRef(ref), 'ref should be sha256 + extension: ' + ref); + assert.strictEqual(bytes, Buffer.from(png, 'base64').length); + assert.strictEqual(S.read(root, slug, ref), png); + assert.strictEqual(S.mediaTypeOf(ref), 'image/png'); +}); + +test('CONTENT ADDRESSED: the same screenshot twice is one file', () => { + // Exactly what happens when someone re-pastes after a send fails. + const a = S.put(root, slug, png, 'image/png'); + const b = S.put(root, slug, png, 'image/png'); + assert.strictEqual(a.ref, b.ref); + const files = fs.readdirSync(S.mediaDir(root, slug)).filter((f) => f.endsWith('.png')); + assert.strictEqual(files.length, 1, 'a duplicate paste must not write a second file'); +}); + +test('CONTENT ADDRESSED: different bytes get different refs', () => { + const other = Buffer.from('ffd8ffe000104a464946', 'hex').toString('base64'); + assert.notStrictEqual(S.put(root, slug, png, 'image/png').ref, + S.put(root, slug, other, 'image/jpeg').ref); +}); + +test('NO TMP LEFT BEHIND: a completed write leaves only the final file', () => { + assert.ok(!fs.readdirSync(S.mediaDir(root, slug)).some((f) => f.includes('.tmp')), + 'tmp+rename must not leave a .tmp file behind'); +}); + +test('REFUSED: unsupported media type, empty bytes, and oversize', () => { + assert.throws(() => S.put(root, slug, png, 'image/tiff'), /unsupported media type/); + assert.throws(() => S.put(root, slug, png, 'image/svg+xml'), /unsupported media type/); + assert.throws(() => S.put(root, slug, '', 'image/png'), /empty image/); + const huge = Buffer.alloc(S.MAX_BYTES + 1).toString('base64'); + assert.throws(() => S.put(root, slug, huge, 'image/png'), /over the/); +}); + +test('REFS ARE NOT PATHS: traversal and junk are rejected, not read', () => { + // read() takes a ref straight from a session file, which is data on disk that a user could edit. + for (const bad of ['../../etc/passwd', '../secrets.png', 'a/b.png', 'notahash.png', + 'a'.repeat(64) + '.exe', 'a'.repeat(63) + '.png', '', null, undefined]) { + assert.strictEqual(S.isRef(bad), false, 'should not look like a ref: ' + String(bad)); + assert.strictEqual(S.read(root, slug, bad), null, 'must not read: ' + String(bad)); + assert.strictEqual(S.mediaTypeOf(bad), null); + } +}); + +test('MATERIALIZE: a ref becomes a wire block only when a request is built', () => { + const { ref } = S.put(root, slug, png, 'image/png'); + const out = S.materialize(root, slug, { type: 'image', ref, w: 100, h: 50 }); + assert.deepStrictEqual(out, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }); + // already-materialized blocks pass through untouched + const inline = { type: 'image', source: { type: 'url', url: 'https://x.test/a.png' } }; + assert.strictEqual(S.materialize(root, slug, inline), inline); + assert.deepStrictEqual(S.materialize(root, slug, { type: 'text', text: 'hi' }), { type: 'text', text: 'hi' }); +}); + +test('MATERIALIZE: a missing file throws rather than sending a request without its subject', () => { + assert.throws( + () => S.materialize(root, slug, { type: 'image', ref: 'b'.repeat(64) + '.png' }), + /missing from disk/, + 'a silently dropped image is the exact failure this feature exists to avoid' + ); +}); + +test('REFS IN: the keep-set sees every attached image and nothing else', () => { + const r1 = 'a'.repeat(64) + '.png', r2 = 'c'.repeat(64) + '.webp'; + const got = S.refsIn([ + { role: 'user', content: [{ type: 'image', ref: r1 }, { type: 'text', text: 'x' }] }, + { role: 'user', content: 'a plain string turn' }, + { role: 'user', content: [{ type: 'image', ref: r2 }, { type: 'image', ref: '../evil' }] } + ]); + assert.deepStrictEqual([...got].sort(), [r1, r2].sort()); +}); + +test('METER: the storage shape does not change the number', () => { + // This is the point of the whole design. An image costs what it costs; whether the bytes are + // inline or on disk behind a ref must not move the meter. + const big = 'A'.repeat(1_400_000); + const inline = [{ role: 'user', content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: big } }, + { type: 'text', text: 'why?' }] }]; + const ref = [{ role: 'user', content: [ + { type: 'image', ref: 'a'.repeat(64) + '.png', w: 3840, h: 2160 }, + { type: 'text', text: 'why?' }] }]; + + const a = estimateMsgTokens(inline, 'claude-opus-5'); + const b = estimateMsgTokens(ref, 'claude-opus-5'); + assert.strictEqual(a, b, 'inline and ref must cost the same'); + assert.ok(a < 6000, 'a 1MB image must not book six figures of tokens — got ' + a); + assert.ok(a > 4000, 'nor may it be under-counted as a short string — got ' + a); + assert.ok(estimateMsgTokens(ref, 'gpt-4o') < b, 'the standard tier costs less than high-res'); +}); + +test('METER: text-only messages are unchanged by any of this', () => { + const msgs = [{ role: 'user', content: 'hello there' }, { role: 'assistant', content: 'hi' }]; + assert.strictEqual(estimateMsgTokens(msgs), Math.round( + msgs.reduce((n2, m) => n2 + JSON.stringify(m).length, 0) / 4), + 'the old heuristic must still hold exactly for text'); +}); + +try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } +console.log('\nimageStore: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/translate.test.js b/extensions/levelcode-ai/test/translate.test.js index 28c2359..566bc15 100644 --- a/extensions/levelcode-ai/test/translate.test.js +++ b/extensions/levelcode-ai/test/translate.test.js @@ -261,4 +261,96 @@ test('isAnthropicFamily: gates cache_control writes to Claude upstreams only', ( assert.strictEqual(O.isAnthropicFamily(''), false); }); +// ── images (I1) ───────────────────────────────────────────────────────────────────────────────── + +test('IMAGE: a base64 block becomes an OpenAI image_url data URI, ahead of the text', () => { + const out = T.toOpenAIMessages('', [{ role: 'user', content: [ + { type: 'text', text: 'why does this look wrong?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAB' } } + ] }]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0].role, 'user'); + assert.ok(Array.isArray(out[0].content), 'a turn carrying an image must use block content'); + // Images lead: the model reads them best before the text, and it keeps the cache breakpoint + // (which lands on the LAST block) on text rather than on an image. + assert.strictEqual(out[0].content[0].type, 'image_url', 'the image must come first'); + assert.strictEqual(out[0].content[0].image_url.url, 'data:image/png;base64,AAAB'); + assert.strictEqual(out[0].content[1].type, 'text'); + assert.strictEqual(out[0].content[1].text, 'why does this look wrong?'); +}); + +test('IMAGE: a url source passes through as a url, not re-encoded', () => { + const out = T.toOpenAIMessages('', [{ role: 'user', content: [ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } } + ] }]); + assert.strictEqual(out[0].content[0].image_url.url, 'https://example.test/a.png'); +}); + +test('IMAGE: several images in one turn all survive', () => { + const out = T.toOpenAIMessages('', [{ role: 'user', content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'A' } }, + { type: 'image', source: { type: 'base64', media_type: 'image/webp', data: 'B' } }, + { type: 'text', text: 'compare these' } + ] }]); + assert.strictEqual(out[0].content.filter((c) => c.type === 'image_url').length, 2); + assert.strictEqual(out[0].content[2].text, 'compare these'); +}); + +test('IMAGE: a text-only turn still emits a plain string, not a block array', () => { + // Widening every text-only turn would change the bytes of every cached prefix for no gain. + const out = T.toOpenAIMessages('', [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }]); + assert.strictEqual(typeof out[0].content, 'string', 'text-only turns must not become block arrays'); + assert.strictEqual(out[0].content, 'hello'); +}); + +test('IMAGE: an image with no text emits the image alone', () => { + const out = T.toOpenAIMessages('', [{ role: 'user', content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'A' } } + ] }]); + assert.strictEqual(out.length, 1); + assert.strictEqual(out[0].content.length, 1); + assert.strictEqual(out[0].content[0].type, 'image_url'); +}); + +test('LOUD: an unrecognised block throws instead of vanishing', () => { + // THE bug this slice exists for. The loop used to fall through on anything it did not know, so + // the block disappeared between composer and wire with no error and no log line — and the model + // answered confidently about content it was never sent. + assert.throws( + () => T.toOpenAIMessages('', [{ role: 'user', content: [ + { type: 'text', text: 'look at this' }, + { type: 'video', source: { type: 'base64', media_type: 'video/mp4', data: 'A' } } + ] }]), + /unsupported content block/, + 'an unknown block type must fail loudly, naming the type' + ); +}); + +test('LOUD: a malformed image throws rather than sending a request missing its subject', () => { + const bad = [ + [{ type: 'image' }, /no source/], + [{ type: 'image', source: { type: 'base64', media_type: 'image/png' } }, /media_type or data/], + [{ type: 'image', source: { type: 'base64', data: 'A' } }, /media_type or data/], + [{ type: 'image', source: { type: 'url' } }, /no url/], + // Files API references are Anthropic-only; there is nothing to translate them to. + [{ type: 'image', source: { type: 'file', file_id: 'file_1' } }, /source type not supported/] + ]; + for (const [block, re] of bad) { + assert.throws(() => T.toOpenAIMessages('', [{ role: 'user', content: [block] }]), re, + 'malformed image should throw: ' + JSON.stringify(block)); + } +}); + +test('IMAGE: tool_result still splits out to its own tool message alongside an image', () => { + const out = T.toOpenAIMessages('', [{ role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'tu_1', content: 'ok' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'A' } }, + { type: 'text', text: 'and this' } + ] }]); + assert.strictEqual(out[0].role, 'tool'); + assert.strictEqual(out[0].tool_call_id, 'tu_1'); + assert.strictEqual(out[1].role, 'user'); + assert.strictEqual(out[1].content[0].type, 'image_url'); +}); + console.log('\ntranslate: ' + n + ' tests passed.'); diff --git a/extensions/levelcode-ai/test/webviewCss.test.js b/extensions/levelcode-ai/test/webviewCss.test.js index 10c6638..5c4056a 100644 --- a/extensions/levelcode-ai/test/webviewCss.test.js +++ b/extensions/levelcode-ai/test/webviewCss.test.js @@ -773,4 +773,24 @@ test('SHELL COLUMN: no member re-declares an inline margin, which would un-centr } }); +test('GROUP CONTAINER: rows are inset from the border, and the border is the only container', () => { + // Reported against the reference: text starting a couple of pixels off the border reads as + // overflowing its container even when it is not, and a tinted fill UNDER a border is two + // containers drawn on top of each other — which is what made these rows look like a slab. + const body = /\.tl-group \.groupbody \{([^}]*)\}/.exec(cssBlocks); + assert.ok(body, 'the group container rule is gone'); + assert.match(body[1], /background:\s*transparent/, + 'no fill — the hairline is the container, and a tint fights whatever ground the theme paints'); + assert.match(body[1], /border:\s*1px solid/, 'the hairline must still be there'); + assert.match(body[1], /overflow:\s*hidden/, + 'without this the first and last rows square off the corners the radius just rounded'); + + const row = /\.tl-group \.groupbody > \.tl \{([^}]*)\}/.exec(cssBlocks); + assert.ok(row, 'the row inset rule is gone'); + const pad = /padding:\s*(\d+)px\s+(\d+)px/.exec(row[1]); + assert.ok(pad, 'rows need explicit padding: ' + row[1]); + assert.ok(Number(pad[2]) >= 10, + 'rows need real horizontal inset from the border — got ' + pad[2] + 'px'); +}); + console.log('webviewCss: ' + n + ' tests passed'); diff --git a/patches/levelcode-core.patch b/patches/levelcode-core.patch index 0f1c1ef..ebc98c0 100644 --- a/patches/levelcode-core.patch +++ b/patches/levelcode-core.patch @@ -391,3 +391,119 @@ index 85e34cca..3f9b67ba 100644 if (typeof releaseDate === 'number' && releaseDate > 0) { this.releaseDateNode.textContent = localize('updateTooltip.releasedLabel', "Released {0}", formatDate(releaseDate)); this.releaseDateNode.style.display = ''; +diff --git a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts +index f9c80ed1..7ba1a81c 100644 +--- a/src/vs/workbench/browser/parts/editor/editorDropTarget.ts ++++ b/src/vs/workbench/browser/parts/editor/editorDropTarget.ts +@@ -18,7 +18,8 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; + import { activeContrastBorder } from '../../../../platform/theme/common/colorRegistry.js'; + import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; + import { isTemporaryWorkspace, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +-import { CodeDataTransfers, containsDragType, Extensions as DragAndDropExtensions, IDragAndDropContributionRegistry, LocalSelectionTransfer } from '../../../../platform/dnd/browser/dnd.js'; ++import { ICommandService } from '../../../../platform/commands/common/commands.js'; // [LevelCode] image drop → chat ++import { CodeDataTransfers, containsDragType, Extensions as DragAndDropExtensions, getPathForFile, IDragAndDropContributionRegistry, LocalSelectionTransfer } from '../../../../platform/dnd/browser/dnd.js'; + import { DraggedEditorGroupIdentifier, DraggedEditorIdentifier, extractTreeDropData, ResourcesDropHandler } from '../../dnd.js'; + import { IEditorGroupsView, IEditorGroupView, prepareMoveCopyEditors } from './editor.js'; + import { EditorInputCapabilities, IEditorIdentifier, IUntypedEditorInput } from '../../../common/editor.js'; +@@ -69,7 +70,8 @@ class DropOverlay extends Themable { + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, + @ITreeViewsDnDService private readonly treeViewsDragAndDropService: ITreeViewsDnDService, +- @IWorkspaceContextService private readonly contextService: IWorkspaceContextService ++ @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, ++ @ICommandService private readonly commandService: ICommandService // [LevelCode] image drop → chat + ) { + super(themeService); + +@@ -365,11 +367,80 @@ class DropOverlay extends Themable { + + // Check for URI transfer + else { ++ // [LevelCode] An image dropped onto the AI chat editor is an ATTACHMENT, not a file to open. ++ // Without this the workbench wins the drop and opens the picture in its own tab, and the ++ // chat webview never sees an event at all — a webview iframe is never offered an OS file ++ // drop, so no amount of handling inside the panel can recover it. ++ if (await this.tryLevelCodeChatImageDrop(event, splitDirection)) { ++ return; ++ } ++ + const dropHandler = this.instantiationService.createInstance(ResourcesDropHandler, { allowWorkspaceOpen: !isWeb || isTemporaryWorkspace(this.contextService.getWorkspace()) }); + dropHandler.handleDrop(event, getWindow(this.groupView.element), () => ensureTargetGroup(), targetGroup => targetGroup?.focus()); + } + } + ++ /** ++ * [LevelCode] Route an image drop to the AI chat instead of opening it as an editor. ++ * ++ * Returns true when the drop was consumed. Deliberately narrow — every condition below is a ++ * reason NOT to change behaviour someone already relies on: ++ * ++ * • only when the chat webview is the ACTIVE editor of the group being dropped on, so dropping ++ * onto any other tab still opens the file; ++ * • only when no split is being requested, so dragging to an edge still splits the group; ++ * • only for image extensions, and only when EVERY dropped file is one, so a mixed drop is ++ * handled the way it always was; ++ * • only when the paths resolve (getPathForFile is native-only, undefined on web). ++ * ++ * The paths go to the extension by command rather than through a new IPC channel: the extension ++ * already owns reading, normalizing and attaching them, so this patch stays a routing decision ++ * and nothing more. That matters for a fork — the smaller the diff, the cheaper every rebase. ++ */ ++ private async tryLevelCodeChatImageDrop(event: DragEvent, splitDirection?: GroupDirection): Promise { ++ if (typeof splitDirection === 'number') { ++ return false; // a split was asked for — that is a layout gesture, not an attachment ++ } ++ ++ // An extension-created webview panel does NOT keep the viewType the extension asked for: the ++ // API layer prefixes it (WebviewViewTypeTransformer('mainThreadWebview-') in ++ // mainThreadWebviewPanels.ts), so the input here reads 'mainThreadWebview-levelcode.ai.chat'. ++ // Matching only the bare id made this whole check silently false and the patch inert — a plain ++ // drag still opened the file, and only a Shift-drag appeared to work, because Shift skips this ++ // overlay entirely and lets the webview see the drop itself. ++ const activeEditor = this.groupView.activeEditor as { viewType?: string } | null | undefined; ++ if (!activeEditor || !LEVELCODE_CHAT_VIEW_TYPES.includes(activeEditor.viewType ?? '')) { ++ return false; ++ } ++ ++ const files = event.dataTransfer?.files; ++ if (!files?.length) { ++ return false; ++ } ++ ++ const paths: string[] = []; ++ for (const file of Array.from(files)) { ++ const filePath = getPathForFile(file); ++ if (!filePath || !/\.(png|jpe?g|gif|webp)$/i.test(filePath)) { ++ return false; ++ } ++ paths.push(filePath); ++ } ++ ++ if (!paths.length) { ++ return false; ++ } ++ ++ try { ++ await this.commandService.executeCommand('levelcode.ai.attachImagePaths', paths); ++ return true; ++ } catch (error) { ++ // If the extension is not there to take them, fall through and open the files as usual. ++ // A dropped image doing nothing at all would be worse than opening it. ++ return false; ++ } ++ } ++ + private isCopyOperation(e: DragEvent, draggedEditor?: IEditorIdentifier): boolean { + if (draggedEditor?.editor.hasCapability(EditorInputCapabilities.Singleton)) { + return false; // Singleton editors cannot be split +@@ -561,6 +632,10 @@ class DropOverlay extends Themable { + } + } + ++// [LevelCode] The chat panel's viewType, in both the form the extension registers and the form the ++// webview API layer rewrites it to once it becomes an editor input. ++const LEVELCODE_CHAT_VIEW_TYPES = ['levelcode.ai.chat', 'mainThreadWebview-levelcode.ai.chat']; ++ + export class EditorDropTarget extends Themable { + + private _overlay?: DropOverlay;