Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 55 additions & 8 deletions editors/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ work; the extension is the client.
- **Chat view** in its own activity-bar container (the `</>` icon). Streams the
agent's text (a small markdown subset, rendered as text nodes; model output
is never inserted as HTML, and the page runs under a strict
Content-Security-Policy), its thinking, each tool call with its status,
Content-Security-Policy: everything `'none'` but `media/`, the two
nonce'd scripts, and `data:` images for the attachment thumbnails), its thinking, each tool call with its status,
output and diffs, and the plan.
- **Slash commands**: type `/` for completion from the commands incode offers
(`available_commands_update`). Tab or Enter picks one.
Expand All @@ -22,6 +23,30 @@ work; the extension is the client.
- **Stop** cancels the running turn (`session/cancel`); any open permission
question is answered `cancelled`.
- **Usage**: the context window's fill (`usage_update`), top right.
- **Reopening a session**: `incode: Open Session…` (also a button in the chat
view's title bar) asks the agent for the sessions kept in the workspace
(`session/list`, following `nextCursor`), shows them in a QuickPick with
their title and when they were last updated, and opens the one you pick
(`session/load`). The current session is closed first, the view is
cleared, and the agent's replay of the history (`user_message_chunk` /
`agent_message_chunk`, sent before it answers) is shown as it arrives. Only
offered when the agent advertises both `loadSession: true` and
`sessionCapabilities.list`; otherwise the button is hidden and the command
says so. `incode: New Session` still starts a fresh one.
- **Pictures in a prompt**: paste or drop a picture into the input, or use the
**Image** button (the editor's file dialog). Each shows as a thumbnail with
a × to remove it, and is sent with the text as an ACP `image` block
(base64 `data` and `mimeType`). Only when the agent advertises
`promptCapabilities.image`; a prompt needs text beside its pictures. Only
PNG and JPEG, recognised from the file's own bytes, since those are what
incode accepts. Bounds, from incode's own: at most 3 750 000 bytes per
picture (`MAX_IMAGE_BYTES`; its base64 is 5 000 000 characters, where model
endpoints commonly stop taking an image), at most 4 per prompt, and 5 MiB
together, because a prompt is one JSON-RPC line and incode drops a line
over 8 MiB (base64 makes 5 MiB about 6.7 MiB, leaving room for the text).
The page refuses a file before reading it; the extension checks everything
again (`src/images.ts`). Sent pictures are named in the log, not kept: the
bytes never enter the view's replay.
- **Files through the editor**: the extension advertises `fs.readTextFile` and
`fs.writeTextFile`. A read returns the open editor buffer, unsaved changes
included, when the file is open. A write is applied as a `WorkspaceEdit`
Expand All @@ -37,7 +62,8 @@ work; the extension is the client.
| --- | --- |
| `incode: Open Chat` (`incode.openChat`) | Show the chat view. The agent starts when the view first opens. |
| `incode: New Session` (`incode.newSession`) | Close the current session and start a fresh one on the same process. |
| `incode: Send Selection to Chat` (`incode.sendSelection`) | Put the selection (or the current line) into the chat input as `path:start-end` and a fenced block, so you can add your question. Also in the editor context menu. |
| `incode: Open Session…` (`incode.openSession`) | Pick one of the workspace's kept sessions and open it again, with its history. Needs an agent that offers `session/list` and `session/load`. |
| `incode: Send Selection to Chat` (`incode.sendSelection`) | Put the selection (or the current line) into the chat input as `path:start-end` and a fenced block, so you can add your question. With `incode.sendSelectionImmediately` it is sent at once instead. Also in the editor context menu. |
| `incode: Restart Agent` (`incode.restart`) | Stop the process and start it again. Use after changing settings or when it exits. |

### Settings
Expand All @@ -46,6 +72,7 @@ work; the extension is the client.
| --- | --- | --- |
| `incode.path` | `incode` | The binary. A bare name is looked up on `PATH`. |
| `incode.args` | `[]` | Extra arguments after `--acp`, e.g. `["-p", "read-only"]` or `["-m", "<model>"]`. |
| `incode.sendSelectionImmediately` | `false` | `Send Selection to Chat` sends the selection as a prompt at once instead of filling the input. While a turn is running it still goes to the input. Read each time, no restart needed. |

The process runs with the first workspace folder as its working directory, and
the session is opened on that folder (`session/new` `cwd`). A window with no
Expand Down Expand Up @@ -76,22 +103,41 @@ compiles, then runs the unit tests with `node:test`, offline:
stdio: initialize, session/new, a streamed prompt, set_mode and
set_config_option, the permission round trip, `fs/*` requests including
out-of-workspace refusal, cancel (also of an open permission question),
malformed lines, the agent exiting mid-turn, a missing binary. It also covers
the JSON-RPC layer over in-memory streams (framing, over-long lines, errors).
malformed lines, the agent exiting mid-turn, a missing binary,
`session/list` paging, `session/load` replaying before it answers, image
blocks. It also covers the JSON-RPC layer over in-memory streams (framing,
over-long lines, errors). The fake agent drops `loadSession`/`list` with
`--no-reopen` and `promptCapabilities.image` with `--no-image`.
- `test/controller.test.ts`: the controller and the fs bridge with a minimal
fake `vscode` module (`test/fakeVscode.ts`) against the same fake agent:
what the chat view is sent for a turn, permission cards answered and
cancelled, reads returning unsaved buffers, writes going through a
`WorkspaceEdit`, refusals outside the workspace, link opening limited to
http(s), restart after the agent exits, and replay into a reopened view.
(Skipped on Windows, where the fixture cannot be run by its shebang.)
http(s), restart after the agent exits, replay into a reopened view,
Open Session (every page listed, the history shown, the old session closed,
an unknown session's error), pictures sent and refused (type, spoofed
type, size, count; the file dialog), Send Selection with and without
`incode.sendSelectionImmediately`, and an agent without `loadSession` or
`image`. The fixture is started with node (`runJsAgentsWithNode`), so this
runs on Windows too.
- `test/extension.test.ts`: `activate` against the fake module: the commands
registered are exactly the ones `package.json` contributes; Send Selection
and Open Session end to end.
- `test/chatPage.test.ts`: `media/chat.js` in a small fake DOM built from the
shell `src/chatView.ts` serves: pictures pasted, dropped and picked, the
thumbnail and its remove control, refusals before a file is read, what a
send carries; and the CSP.
- `test/images.test.ts`: the picture rules (`src/images.ts`) at each bound.
- `test/paths.test.ts`: the workspace path check as a pure function (POSIX
and Windows rules, `..`, prefix tricks, symlinks), line slicing for
`fs/read_text_file`, and the selection format.
- `test/markdown.test.ts`: the markdown-lite renderer never produces HTML from
model text and only links `http(s)` URLs.
- `test/real-incode.test.ts`: initialize and session/new against the real
binary. Skipped unless `INCODE_BIN` is set:
- `test/real-incode.test.ts`: against the real binary: initialize and
session/new; the reopen and image capabilities; `session/list` and
`session/load` of a transcript the test writes into
`.incode/transcripts`; incode decoding an `image` block (a GIF is refused
by its type). No prompt reaches a model. Skipped unless `INCODE_BIN` is set:

```sh
cargo build --release # at the repo root
Expand Down Expand Up @@ -121,6 +167,7 @@ or in VS Code: Extensions view, `...` menu, **Install from VSIX...**.
| `src/acp.ts` | The ACP client, independent of VS Code: framing, JSON-RPC correlation, notifications, agent→client requests (permission, fs) through callbacks, cancel, spawning. |
| `src/paths.ts` | The workspace path rule and line slicing. Pure. |
| `src/selection.ts` | The text `sendSelection` produces. Pure. |
| `src/images.ts` | Which pictures a prompt may carry, and the bounds. Pure. |
| `src/fsBridge.ts` | `fs/read_text_file` and `fs/write_text_file` on top of the editor. |
| `src/controller.ts` | One agent process and session per window; between the agent and the view. |
| `src/chatView.ts` | The webview, its CSP and HTML shell. |
Expand Down
36 changes: 36 additions & 0 deletions editors/vscode/media/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,42 @@ textarea {
textarea:focus {
outline: 1px solid var(--vscode-focusBorder);
}
#attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 4px;
}
#attachments[hidden] {
display: none;
}
.thumb {
position: relative;
width: 48px;
height: 48px;
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border, transparent));
border-radius: 3px;
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumb .remove {
position: absolute;
top: 0;
right: 0;
padding: 0 4px;
line-height: 1.2;
border-radius: 0 0 0 3px;
}
.msg.user .attached {
margin-top: 4px;
font-size: 0.9em;
color: var(--vscode-descriptionForeground);
}
.actions {
display: flex;
justify-content: flex-end;
Expand Down
192 changes: 188 additions & 4 deletions editors/vscode/media/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@
var modeSel = $("mode");
var configEl = $("config");
var completions = $("completions");
var composer = $("composer");
var attachBtn = $("attach");
var attachmentsEl = $("attachments");

// Pictures for the next prompt. The bounds are src/images.ts's, repeated
// here so a file is refused before it is read; the extension checks again.
var IMAGE_TYPES = ["image/png", "image/jpeg"];
var MAX_IMAGE_BYTES = 3750000;
var MAX_IMAGES = 4;
var MAX_TOTAL_IMAGE_BYTES = 5 * 1024 * 1024;
var imagesAllowed = false;
var attachments = []; // { name, mimeType, data (base64), size }
var draftSeq = 0;
var pending = null; // { draft, text, attachments }: sent, not yet answered

var commands = [];
var running = false;
Expand Down Expand Up @@ -323,12 +337,157 @@

function send() {
var text = input.value;
if (!text.trim() || running) return;
vscode.postMessage({ type: "send", text: text });
if (running) return;
if (!text.trim()) {
if (attachments.length > 0) localNotice("info", "Add a question to go with the picture.");
return;
}
// The draft is kept until the extension says it went: a refusal (a turn
// running, no session, a picture the agent cannot take) puts it back.
pending = { draft: ++draftSeq, text: text, attachments: attachments };
var message = { type: "send", text: text, draft: pending.draft };
if (attachments.length > 0) {
message.images = attachments.map(function (a) {
return { name: a.name, mimeType: a.mimeType, data: a.data };
});
}
vscode.postMessage(message);
input.value = "";
attachments = [];
renderAttachments();
updateCompletions();
}

/** The extension refused draft `m.draft`: restore what has not been replaced since. */
function restoreDraft(m) {
if (!pending || pending.draft !== m.draft) return;
var draft = pending;
pending = null;
if (!input.value.trim()) input.value = draft.text;
if (attachments.length === 0 && draft.attachments.length > 0) {
attachments = draft.attachments;
renderAttachments();
}
}

// ----- pictures -----------------------------------------------------------

function localNotice(level, text) {
endStream();
append(el("div", "notice " + level, text));
}

function formatBytes(n) {
if (n >= 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MiB";
if (n >= 1024) return Math.round(n / 1024) + " KiB";
return n + " bytes";
}

/** Why a picture of this type and size cannot be added now, or null. */
function refusal(name, type, size) {
if (!imagesAllowed) return "this agent does not take pictures";
if (IMAGE_TYPES.indexOf(type) < 0) return name + " is " + (type || "not a picture") + "; only PNG and JPEG pictures are accepted";
if (size > MAX_IMAGE_BYTES) return name + " is " + formatBytes(size) + "; a picture may be at most " + formatBytes(MAX_IMAGE_BYTES);
if (attachments.length >= MAX_IMAGES) return "at most " + MAX_IMAGES + " pictures go with one prompt";
var total = size;
attachments.forEach(function (a) {
total += a.size;
});
if (total > MAX_TOTAL_IMAGE_BYTES) return "the pictures would come to more than " + formatBytes(MAX_TOTAL_IMAGE_BYTES) + " together";
return null;
}

function addAttachment(a) {
var why = refusal(a.name, a.mimeType, a.size);
if (why) {
localNotice("error", "Not attached: " + why + ".");
return;
}
attachments.push(a);
renderAttachments();
}

/** A File from paste, drop or the clipboard: checked, then read as base64. */
function addFile(file) {
var name = file.name || "pasted picture";
var why = refusal(name, file.type, file.size);
if (why) {
localNotice("error", "Not attached: " + why + ".");
return;
}
var reader = new FileReader();
reader.onload = function () {
var url = String(reader.result || "");
var comma = url.indexOf(",");
if (comma < 0) return;
addAttachment({ name: name, mimeType: file.type, data: url.slice(comma + 1), size: file.size });
};
reader.onerror = function () {
localNotice("error", "Not attached: " + name + " could not be read.");
};
reader.readAsDataURL(file);
}

function renderAttachments() {
while (attachmentsEl.firstChild) attachmentsEl.removeChild(attachmentsEl.firstChild);
attachmentsEl.hidden = attachments.length === 0;
attachments.forEach(function (a, i) {
var box = el("div", "thumb");
box.title = a.name + " (" + formatBytes(a.size) + ")";
var img = el("img");
img.alt = a.name;
// A data: URL of the picture's own bytes; the CSP allows data: for img-src only.
img.src = "data:" + a.mimeType + ";base64," + a.data;
box.appendChild(img);
var remove = el("button", "remove", "×");
remove.type = "button";
remove.title = "Remove " + a.name;
remove.setAttribute("aria-label", "Remove " + a.name);
remove.addEventListener("click", function () {
attachments.splice(i, 1);
renderAttachments();
});
box.appendChild(remove);
attachmentsEl.appendChild(box);
});
}

function listOf(files) {
var out = [];
for (var i = 0; files && i < files.length; i++) out.push(files[i]);
return out;
}

input.addEventListener("paste", function (e) {
var items = (e.clipboardData && e.clipboardData.items) || [];
var files = [];
for (var i = 0; i < items.length; i++) {
if (items[i].kind === "file") {
var f = items[i].getAsFile();
if (f) files.push(f);
}
}
// Text pastes as text; only a paste that carries files is taken over.
if (files.length === 0) return;
e.preventDefault();
files.forEach(addFile);
});
// Files dropped from the system's file manager. (VS Code's own explorer
// drags URIs, not files; those are left to the editor.)
composer.addEventListener("dragover", function (e) {
var types = (e.dataTransfer && e.dataTransfer.types) || [];
if (Array.prototype.indexOf.call(types, "Files") >= 0) e.preventDefault();
});
composer.addEventListener("drop", function (e) {
var files = listOf(e.dataTransfer && e.dataTransfer.files);
if (files.length === 0) return;
e.preventDefault();
files.forEach(addFile);
});
attachBtn.addEventListener("click", function () {
vscode.postMessage({ type: "attachImage" });
});

sendBtn.addEventListener("click", send);
stopBtn.addEventListener("click", function () {
vscode.postMessage({ type: "stop" });
Expand All @@ -350,9 +509,34 @@
statusEl.textContent = m.text || "";
statusEl.className = m.state;
break;
case "user":
case "user": {
endStream();
append(el("div", "msg user", m.text));
var box = el("div", "msg user", m.text);
if (Array.isArray(m.images) && m.images.length > 0) {
box.appendChild(el("div", "attached", "Attached: " + m.images.join(", ")));
}
append(box);
break;
}
case "capabilities":
imagesAllowed = !!m.images;
attachBtn.hidden = !imagesAllowed;
if (!imagesAllowed && attachments.length > 0) {
attachments = [];
renderAttachments();
}
break;
case "sent":
if (pending && pending.draft === m.draft) pending = null;
break;
case "notSent":
restoreDraft(m);
break;
case "attachment":
if (m.image) {
var bytes = Math.floor((String(m.image.data || "").length * 3) / 4);
addAttachment({ name: m.image.name, mimeType: m.image.mimeType, data: m.image.data, size: bytes });
}
break;
case "agentText":
streamText("agentText", m.text);
Expand Down
Loading
Loading