Skip to content

Commit 119fef2

Browse files
authored
Merge pull request #90 from levelcodeai/feat/image-input-impl
feat(images): paste a screenshot, ask about it
2 parents 3172bed + 59bdfd0 commit 119fef2

19 files changed

Lines changed: 2282 additions & 40 deletions

docs/CORE-PATCHES.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ So a full rebuild from nothing is: `bootstrap.sh` (clone → brand → extension
2626
To re-create the core patch after changing core files in `vscode/`:
2727

2828
```bash
29-
# STRUCTURAL patch only (14 files). Display-string rebrands are NOT here — they live in scripts/de-brand.mjs.
29+
# STRUCTURAL patch only (15 files). Display-string rebrands are NOT here — they live in scripts/de-brand.mjs.
3030
git -C vscode diff HEAD -- \
3131
src/vs/workbench/contrib/files/browser/files.contribution.ts \
3232
build/lib/extensions.ts build/lib/copilot.ts \
@@ -41,6 +41,7 @@ git -C vscode diff HEAD -- \
4141
src/vs/base/common/product.ts \
4242
src/vs/platform/dialogs/electron-browser/dialog.ts \
4343
src/vs/workbench/contrib/update/browser/updateTooltip.ts \
44+
src/vs/workbench/browser/parts/editor/editorDropTarget.ts \
4445
> patches/levelcode-core.patch
4546
# NOTE 1: use `diff HEAD` (not plain `diff`) — bootstrap's `git apply` may leave these STAGED,
4647
# and plain `git diff` shows only UNSTAGED changes, silently dropping the staged patches.
@@ -64,6 +65,34 @@ grep -rn "\[LevelCode\]" vscode/src vscode/build
6465

6566
## Patches (structural / behavioural only)
6667

68+
### `editorDropTarget.ts` — an image dropped on the chat is an attachment, not a file to open
69+
70+
**Why it has to be here.** A webview iframe is never offered an OS file drop: the workbench takes the
71+
drop first and opens the file in a tab. Nothing inside `extensions/levelcode-ai` can recover it — the
72+
panel's own `drop` handler never fires, and the `text/uri-list` fallback has no event to fall back
73+
from. This is the one part of paste-a-screenshot that cannot be an extension change.
74+
75+
**What it does.** In `DropOverlay.handleDrop`, immediately before the URI-transfer branch hands off to
76+
`ResourcesDropHandler`, `tryLevelCodeChatImageDrop` forwards the dropped paths to the extension via
77+
`levelcode.ai.attachImagePaths` and consumes the drop.
78+
79+
**Kept narrow on purpose** — every condition is a reason not to change behaviour someone relies on:
80+
81+
- only when the chat webview is the **active editor of the group being dropped on**, so a drop on any
82+
other tab still opens the file;
83+
- only when **no split** is requested, so dragging to an edge still splits the group;
84+
- only when **every** dropped file is an image, so a mixed drop behaves as it always did;
85+
- only when the paths resolve — `getPathForFile` is native-only and returns undefined on web;
86+
- and if the command throws (extension not activated), it **falls through** to the normal handler,
87+
because a dropped image doing nothing at all is worse than one that opens.
88+
89+
The paths travel by command rather than a new IPC channel, so the diff stays a routing decision and
90+
nothing more — which is what keeps it cheap to re-apply on a rebase.
91+
92+
**Regenerating:** this file was appended per NOTE 3, not swept in by a wholesale regen. A wholesale
93+
regen on a de-branded checkout pulls ~78 lines of link-strips into `files.contribution.ts`; I did that
94+
once while adding this entry and had to back it out.
95+
6796
These can't be a content swap — behaviour, build logic, unregistrations. Kept small on purpose so they
6897
survive Code-OSS bumps. Each is tagged `[LevelCode]`. **The build is strict** (`noUnusedLocals` +
6998
`allowUnreachableCode: false`), so these avoid dead early-`return`s (unreachable-code error), commented-out

extensions/levelcode-ai/agent.js

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,20 @@ async function approveMcpLaunch(ctx, server, dbg) {
633633
return true;
634634
}
635635

636+
/**
637+
* What we last TOLD the user about a run's context (rules / memory / MCP).
638+
*
639+
* The context itself is rebuilt every run — that is deliberate, a run's servers are whatever is
640+
* configured and reachable right now. Re-ANNOUNCING it every turn is different, and it was noise:
641+
* three identical rows at the top of every single answer, saying the same thing they said last time.
642+
*
643+
* A signature, not a boolean, because the announcement has to come back the moment anything moves —
644+
* a server dropping out, a rules file appearing, memory arriving for the first time. Silence is only
645+
* correct while the picture is unchanged.
646+
*/
647+
let lastContextSig = '';
648+
function resetContextAnnounce() { lastContextSig = ''; }
649+
636650
async function setupMcp(ctx, wsFolders, dbg) {
637651
const empty = { tools: [], routes: null };
638652
const cfg = ctx.mcp || {};
@@ -687,7 +701,9 @@ async function setupMcp(ctx, wsFolders, dbg) {
687701
const perServer = toolCountsByServer(built.routes);
688702
const summary = handles.map((h) => h.name + ' (' + (perServer.get(h.name) || 0) + ')').join(', ');
689703
dbg('mcp.ready', { servers: handles.map((h) => h.name), tools: built.tools.length, allowed });
690-
ctx.post({ type: 'agentTool', icon: 'sparkle', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' });
704+
// Handed back rather than posted: runAgent decides whether the user needs to hear it again.
705+
// Failures below still post immediately — a server that broke is news every time.
706+
built.announce = { type: 'agentTool', icon: 'sparkle', text: '🔌 mcp · ' + summary + ' · ' + allowed + '/' + built.tools.length + ' allow-listed' };
691707
return built;
692708
} catch (e) {
693709
dbg('mcp.failed', { error: (e && e.message) || String(e) });
@@ -739,21 +755,36 @@ async function runAgent(ctx) {
739755
const systemTokensEst = Math.round(system.length / 4);
740756

741757
const dbg = ctx.dbg || (() => {});
758+
// The run's context, COLLECTED rather than posted. Whether the user needs to see it again is a
759+
// question about the whole picture, and the MCP part of that picture is not known until setupMcp
760+
// has run — so nothing is announced until all three are in hand.
761+
const contextChips = [];
742762
if (rules.sources.length) {
743763
dbg('projectRules.loaded', { sources: rules.sources });
744-
// Quiet timeline chip at the top of the run so the user can see their repo rules are in effect
745-
// (mirrors the skill chip). Reuses the agentTool → addAgentLine rendering — no webview change.
746-
ctx.post({ type: 'agentTool', icon: 'file', text: '📋 project rules · ' + rules.sources.join(', ') });
764+
contextChips.push({ type: 'agentTool', icon: 'file', text: '📋 project rules · ' + rules.sources.join(', ') });
747765
}
748766
if (ctx.projectMemory) {
749767
dbg('projectMemory.loaded', { chars: ctx.projectMemory.length });
750-
ctx.post({ type: 'agentTool', icon: 'history', text: '🧠 project memory' });
768+
contextChips.push({ type: 'agentTool', icon: 'history', text: '🧠 project memory' });
751769
}
752770

753771
// MCP (docs/MCP.md S3): the tool list becomes PER-RUN. It was a module constant only because it was
754772
// the same every time; a run's servers are whatever is configured and reachable right now. Same shape
755773
// as `system`/`systemTokensEst` two lines up — built once per run, then used for every turn.
756774
const mcp = await setupMcp(ctx, wsFolders, dbg);
775+
if (mcp.announce) { contextChips.push(mcp.announce); }
776+
777+
// Say it only when it CHANGED. The context is rebuilt every run by design; repeating it at the top
778+
// of every answer is not the same thing, and three identical rows before each reply is noise the
779+
// reference transcript does not have. A signature rather than a flag, so the announcement returns
780+
// the moment a server drops, a rules file appears, or memory shows up for the first time.
781+
const sig = contextChips.map((c) => c.text).join('|');
782+
if (sig && sig !== lastContextSig) {
783+
lastContextSig = sig;
784+
for (const chip of contextChips) { ctx.post(chip); }
785+
} else if (!sig) {
786+
lastContextSig = ''; // nothing to say now; say it again when there is
787+
}
757788
ctx.mcpRoutes = mcp.routes; // runTool's router reads this
758789
// Rootless runs get the portable subset; MCP tools are unaffected either way.
759790
const builtins = root ? TOOLS : PORTABLE_TOOLS;
@@ -1030,4 +1061,4 @@ async function runAgent(ctx) {
10301061
}
10311062
}
10321063

1033-
module.exports = { runAgent, makeDiff, resolveWorkspacePath };
1064+
module.exports = { resetContextAnnounce, runAgent, makeDiff, resolveWorkspacePath };

extensions/levelcode-ai/agentMemory.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
*--------------------------------------------------------------------------------------------*/
77
'use strict';
88

9+
10+
const { imageBlockTokens } = require('./imageCost');
911
/** A "goal boundary": a user message with plain STRING content (a fresh user turn, never a tool_result).
1012
* It is the only splice point that cannot orphan a tool_use/tool_result pair — tool results always sit
1113
* 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) {
3436
return cut;
3537
}
3638

37-
/** Rough token estimate for a message list — the house chars/4 heuristic, used only for the UI meter. */
38-
function estimateMsgTokens(msgs) {
39+
/**
40+
* Rough token estimate for a message list — the house chars/4 heuristic, used only for the UI meter.
41+
*
42+
* Images are counted by their real visual cost, not by their JSON. chars/4 is sound for text and
43+
* wrong for an image in whichever shape it takes: inline base64 books about a third of its byte
44+
* count (a 1MB screenshot reads as ~333,000 tokens, more than most context windows, for something
45+
* that really costs ~4,800), and a stored ref swings the other way — 64 hex characters read as ~18
46+
* tokens for the same ~4,800. Both would make the meter lie about how much room is left.
47+
*
48+
* `modelId` picks the resolution tier; omitting it costs the standard tier, which over-counts
49+
* rather than under-counts. See imageCost.js.
50+
*/
51+
function estimateMsgTokens(msgs, modelId) {
3952
if (!Array.isArray(msgs)) { return 0; }
40-
return Math.round(msgs.reduce((n, m) => n + JSON.stringify(m).length, 0) / 4);
53+
let chars = 0;
54+
let imageTokens = 0;
55+
for (const m of msgs) {
56+
if (!m) { continue; }
57+
if (!Array.isArray(m.content)) { chars += JSON.stringify(m).length; continue; }
58+
chars += 24; // role + envelope, roughly what the object costs around its blocks
59+
for (const b of m.content) {
60+
if (b && b.type === 'image') { imageTokens += imageBlockTokens(b, modelId); }
61+
else { chars += JSON.stringify(b).length; }
62+
}
63+
}
64+
return Math.round(chars / 4) + imageTokens;
4165
}
4266

4367
module.exports = { isGoalBoundary, findCompactionCut, estimateMsgTokens };

0 commit comments

Comments
 (0)