From f62dbccb4b6b106226a22ced8a1a640b386d5e82 Mon Sep 17 00:00:00 2001 From: blockmandev <194144408+blockmandev@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:53:01 -0600 Subject: [PATCH 1/2] vscode: reopen kept sessions, attach pictures, send a selection at once (refs #20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incode advertises loadSession, sessionCapabilities.list and promptCapabilities.image, but the extension used none of them: a session could not be found or reopened once the window moved on, a screenshot could not go with a question, and Send Selection could only fill the input. Open Session. `incode: Open Session…` (and a title-bar button, shown only when the agent offers both session/list and session/load) lists the workspace's sessions, following nextCursor, in a QuickPick with title and last update, and loads the pick. The current session is closed first and waited for, because incode refuses to load a session still open on the connection. The loaded id is routed while the request is in flight, since incode replays the history BEFORE it answers; without that the replay was buffered and shown after the answer, or dropped past the early-update cap. Pictures. Paste, drop or the Image button (the editor's file dialog) attach PNG/JPEG pictures, shown as thumbnails with a remove control, sent as ACP image blocks beside the text, only to an agent advertising image. PNG and JPEG only, recognised from their bytes, because those are what incode accepts. Bounds are incode's: 3 750 000 bytes per picture (its MAX_IMAGE_BYTES), 4 per prompt, and 5 MiB together so the prompt's one JSON-RPC line stays under incode's 8 MiB cap. The page refuses a file before reading it; src/images.ts checks everything again in the extension. The bytes never enter the view's replay. The CSP gains `data:` in img-src only, for the thumbnails. incode.sendSelectionImmediately (default false) makes Send Selection send at once; while a turn runs the selection still goes to the input. Tests: the fake agent gains session/list (two pages), session/load with a replay before the answer and the already-open refusal, image prompts, and --no-reopen / --no-image. New tests cover the controller paths and their negatives, extension activation against package.json, chat.js in a fake DOM, and the image rules at each bound; the live test lists and loads a transcript with the real binary and has it decode an image block. The controller tests now start the fixture with node, so they run on Windows instead of being skipped there. --- editors/vscode/README.md | 63 +++- editors/vscode/media/chat.css | 36 +++ editors/vscode/media/chat.js | 169 ++++++++++- editors/vscode/package.json | 15 + editors/vscode/src/acp.ts | 55 +++- editors/vscode/src/chatView.ts | 40 ++- editors/vscode/src/controller.ts | 228 ++++++++++++++- editors/vscode/src/extension.ts | 7 +- editors/vscode/src/images.ts | 104 +++++++ editors/vscode/test/acp.test.ts | 56 +++- editors/vscode/test/chatPage.test.ts | 317 +++++++++++++++++++++ editors/vscode/test/controller.test.ts | 236 +++++++++++++-- editors/vscode/test/extension.test.ts | 112 ++++++++ editors/vscode/test/fakeVscode.ts | 71 ++++- editors/vscode/test/fixtures/fake-agent.js | 97 +++++-- editors/vscode/test/images.test.ts | 63 ++++ editors/vscode/test/real-incode.test.ts | 67 ++++- 17 files changed, 1657 insertions(+), 79 deletions(-) create mode 100644 editors/vscode/src/images.ts create mode 100644 editors/vscode/test/chatPage.test.ts create mode 100644 editors/vscode/test/extension.test.ts create mode 100644 editors/vscode/test/images.test.ts 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..a73c45c 100644 --- a/editors/vscode/media/chat.js +++ b/editors/vscode/media/chat.js @@ -21,6 +21,18 @@ 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 commands = []; var running = false; @@ -323,12 +335,142 @@ 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; + } + var message = { type: "send", text: text }; + 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(); } + // ----- 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 +492,28 @@ 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 "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 {
-