diff --git a/editors/vscode/README.md b/editors/vscode/README.md index fe638d3..419eda6 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -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. @@ -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` @@ -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 @@ -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", ""]`. | +| `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 @@ -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 @@ -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. | diff --git a/editors/vscode/media/chat.css b/editors/vscode/media/chat.css index fe6e911..64e0e3b 100644 --- a/editors/vscode/media/chat.css +++ b/editors/vscode/media/chat.css @@ -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; diff --git a/editors/vscode/media/chat.js b/editors/vscode/media/chat.js index 29099c3..726667b 100644 --- a/editors/vscode/media/chat.js +++ b/editors/vscode/media/chat.js @@ -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; @@ -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" }); @@ -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); diff --git a/editors/vscode/package.json b/editors/vscode/package.json index c4bb340..5f844f1 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -49,6 +49,11 @@ "title": "New Session", "category": "incode" }, + { + "command": "incode.openSession", + "title": "Open Session…", + "category": "incode" + }, { "command": "incode.sendSelection", "title": "Send Selection to Chat", @@ -74,6 +79,11 @@ "when": "view == incode.chat", "group": "navigation" }, + { + "command": "incode.openSession", + "when": "view == incode.chat && incode.canReopenSession", + "group": "navigation" + }, { "command": "incode.restart", "when": "view == incode.chat" @@ -97,6 +107,11 @@ "default": [], "markdownDescription": "Extra arguments passed after `--acp`, e.g. `[\"-p\", \"read-only\"]` or `[\"-m\", \"\"]`.", "scope": "machine-overridable" + }, + "incode.sendSelectionImmediately": { + "type": "boolean", + "default": false, + "markdownDescription": "`incode: Send Selection to Chat` sends the selection as a prompt at once, instead of putting it in the chat input for you to add a question. While a turn is running it still goes to the input." } } } diff --git a/editors/vscode/src/acp.ts b/editors/vscode/src/acp.ts index 377cd7b..e3bcd27 100644 --- a/editors/vscode/src/acp.ts +++ b/editors/vscode/src/acp.ts @@ -89,7 +89,13 @@ export interface ContentBlockResource { type: "resource"; resource: { uri: string; text: string; mimeType?: string }; } -export type ContentBlock = ContentBlockText | ContentBlockResource | { type: string; [k: string]: unknown }; +/** A picture: base64 `data` and its media type. Sent only when the agent advertises `promptCapabilities.image`. */ +export interface ContentBlockImage { + type: "image"; + mimeType: string; + data: string; +} +export type ContentBlock = ContentBlockText | ContentBlockResource | ContentBlockImage | { type: string; [k: string]: unknown }; export interface SessionMode { id: string; @@ -226,6 +232,20 @@ export interface LoadSessionResult { configOptions?: SessionConfigOption[] | null; } +/** One entry of `session/list`. */ +export interface SessionInfo { + sessionId: string; + cwd: string; + title?: string | null; + /** RFC 3339. */ + updatedAt?: string | null; +} + +export interface ListSessionsResult { + sessions: SessionInfo[]; + nextCursor?: string | null; +} + export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled" | string; // --------------------------------------------------------------------------- @@ -614,10 +634,22 @@ export class AcpClient extends EventEmitter { return this.connection.request("session/new", { cwd, mcpServers }); } + /** + * Open a kept session again. The agent replays its history as + * `session/update` notifications BEFORE it answers. + */ loadSession(sessionId: string, cwd: string, mcpServers: unknown[] = []): Promise { return this.connection.request("session/load", { sessionId, cwd, mcpServers }); } + /** One page of the sessions kept in `cwd`. */ + async listSessions(cwd: string, cursor?: string): Promise { + const params: { cwd: string; cursor?: string } = { cwd }; + if (cursor !== undefined) params.cursor = cursor; + const r = await this.connection.request("session/list", params); + return { sessions: Array.isArray(r?.sessions) ? r.sessions.filter(isSessionInfo) : [], nextCursor: r?.nextCursor ?? null }; + } + async prompt(sessionId: string, prompt: ContentBlock[]): Promise { const r = await this.connection.request<{ stopReason: StopReason }>("session/prompt", { sessionId, prompt }); return r?.stopReason ?? "end_turn"; @@ -654,6 +686,27 @@ export class AcpClient extends EventEmitter { const close = this.initialized?.agentCapabilities?.sessionCapabilities?.close; return close !== undefined && close !== null; } + + /** + * Whether a kept session can be found and opened again: `session/load` + * (`loadSession: true`) and `session/list` (`sessionCapabilities.list`) + * both, since one without the other gives the person nothing to pick from + * or nothing to do with the pick. + */ + get supportsReopen(): boolean { + const caps = this.initialized?.agentCapabilities; + const list = caps?.sessionCapabilities?.list; + return caps?.loadSession === true && list !== undefined && list !== null; + } + + /** Whether a prompt may carry `image` blocks. */ + get supportsImages(): boolean { + return this.initialized?.agentCapabilities?.promptCapabilities?.image === true; + } +} + +function isSessionInfo(s: unknown): s is SessionInfo { + return !!s && typeof s === "object" && typeof (s as SessionInfo).sessionId === "string"; } export function defaultClientCapabilities(): object { diff --git a/editors/vscode/src/chatView.ts b/editors/vscode/src/chatView.ts index f8fe2c4..05ce4ed 100644 --- a/editors/vscode/src/chatView.ts +++ b/editors/vscode/src/chatView.ts @@ -45,15 +45,32 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, View { private html(webview: vscode.Webview, media: vscode.Uri): string { const nonce = crypto.randomBytes(16).toString("base64"); - const uri = (f: string): string => webview.asWebviewUri(vscode.Uri.joinPath(media, f)).toString(); - const csp = [ - "default-src 'none'", - `style-src ${webview.cspSource}`, - `font-src ${webview.cspSource}`, - `img-src ${webview.cspSource}`, - `script-src 'nonce-${nonce}'`, - ].join("; "); - return ` + return pageHtml(webview.cspSource, nonce, (f) => webview.asWebviewUri(vscode.Uri.joinPath(media, f)).toString()); + } +} + +/** + * The page's Content-Security-Policy. Everything is `'none'` but what the + * page loads from `media/` and its two nonce'd scripts. `img-src` also allows + * `data:`, and only that: the thumbnail of a picture attached to the input + * is its own bytes as a `data:` URL. An image cannot run script, and + * `connect-src`, `frame-src` and the rest stay `'none'`, so nothing the page + * shows can reach out. + */ +export function contentSecurityPolicy(cspSource: string, nonce: string): string { + return [ + "default-src 'none'", + `style-src ${cspSource}`, + `font-src ${cspSource}`, + `img-src ${cspSource} data:`, + `script-src 'nonce-${nonce}'`, + ].join("; "); +} + +/** The HTML shell; media/chat.js fills it. `uri` maps a file in `media/` to the webview's URI for it. */ +export function pageHtml(cspSource: string, nonce: string, uri: (file: string) => string): string { + const csp = contentSecurityPolicy(cspSource, nonce); + return ` @@ -71,10 +88,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, View {
-