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. -->