From 8660fe4d6f28e531d8d18312d9149750cee50177 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 17:42:25 -0400 Subject: [PATCH 1/2] fix(amicode): paste into the chat composer via the extension clipboard bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat composer runs as a cross-origin iframe inside the VS Code webview, where native paste and navigator.clipboard deliver no clipboard data (the webview parent has no clipboard-read permission to delegate down). handlePaste called event.preventDefault() and then read event.clipboardData.getData(), which comes back empty in that context — so it bailed and inserted nothing, and Cmd+V appeared to do nothing in the chat box. The fork already solved this for the onboarding profile inputs (home-cards.tsx) with an extension clipboard bridge: the iframe posts a clipboard-request, the extension host reads vscode.env.clipboard and replies. That bridge is fully wired end-to-end (chat_panel.ts) but the main chat composer never used it. Add a readClipboardViaBridge() helper (mirrors the home-cards fallback, reuses the amicode postMessage protocol, self-gates to "" outside the webview) and inject it into createPromptAttachments as readClipboardText, used as a fallback in handlePaste when native paste yields no text. No extension-side change: the responder already exists and is gated to the visible panel. Unit-tested (clipboard-bridge.test.ts); no regression for web/desktop where the helper resolves "" immediately without posting. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/src/components/prompt-input.tsx | 3 + .../components/prompt-input/attachments.ts | 12 ++- .../prompt-input/clipboard-bridge.test.ts | 76 +++++++++++++++++++ .../prompt-input/clipboard-bridge.ts | 42 ++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/prompt-input/clipboard-bridge.test.ts create mode 100644 packages/app/src/components/prompt-input/clipboard-bridge.ts diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 184c1c8aa9..51b9c09721 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -57,6 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" +import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, @@ -1087,6 +1088,8 @@ export const PromptInput: Component = (props) => { }, addPart, readClipboardImage: platform.readClipboardImage, + // Webview-iframe paste fallback; self-gates to a no-op outside the webview. + readClipboardText: () => readClipboardViaBridge(), }) const fileAttachmentInput = () => ( diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 2b1cd1826c..c595328052 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -32,6 +32,9 @@ type PromptAttachmentsInput = { focusEditor: () => void addPart: (part: ContentPart) => boolean readClipboardImage?: () => Promise + /** Fallback clipboard-text reader for the VS Code webview iframe, where native + * paste delivers no data. Resolves "" when unavailable (see clipboard-bridge). */ + readClipboardText?: () => Promise } export function createPromptAttachments(input: PromptAttachmentsInput) { @@ -108,7 +111,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { return } - const plainText = clipboardData.getData("text/plain") ?? "" + let plainText = clipboardData.getData("text/plain") ?? "" // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images if (input.readClipboardImage && !plainText) { @@ -119,6 +122,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } } + // Amicode webview: a cross-origin iframe inside the VS Code webview gets no + // clipboard data from native paste, so ask the extension host over the + // amicode bridge before giving up (resolves "" outside the webview). + if (!plainText && input.readClipboardText) { + plainText = await input.readClipboardText() + } + if (!plainText) return const text = normalizePaste(plainText) diff --git a/packages/app/src/components/prompt-input/clipboard-bridge.test.ts b/packages/app/src/components/prompt-input/clipboard-bridge.test.ts new file mode 100644 index 0000000000..372e4a1342 --- /dev/null +++ b/packages/app/src/components/prompt-input/clipboard-bridge.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import { readClipboardViaBridge } from "./clipboard-bridge" + +type Listener = (event: MessageEvent) => void + +// A stand-in for a framed window (parent !== self) that records outgoing +// clipboard-requests and lets a test play back the host's reply. +function fakeFramedWindow() { + const listeners = new Set() + const posted: Array> = [] + const win = { + addEventListener: (_type: string, fn: Listener) => listeners.add(fn), + removeEventListener: (_type: string, fn: Listener) => listeners.delete(fn), + parent: { + postMessage: (message: Record) => posted.push(message), + }, + } as unknown as Window + return { + win, + posted, + reply: (message: Record) => listeners.forEach((fn) => fn({ data: message } as MessageEvent)), + listenerCount: () => listeners.size, + } +} + +describe("readClipboardViaBridge", () => { + test("requests the OS clipboard from the host and resolves with the reply", async () => { + const bridge = fakeFramedWindow() + const pending = readClipboardViaBridge(bridge.win) + + expect(bridge.posted).toHaveLength(1) + const request = bridge.posted[0] + expect(request.source).toBe("amicode") + expect(request.kind).toBe("clipboard-request") + expect(typeof request.nonce).toBe("string") + + bridge.reply({ source: "amicode", kind: "clipboard", nonce: request.nonce, text: "solve a CZ gate" }) + + expect(await pending).toBe("solve a CZ gate") + expect(bridge.listenerCount()).toBe(0) // listener cleaned up + }) + + test("ignores replies whose nonce does not match the request", async () => { + const bridge = fakeFramedWindow() + const pending = readClipboardViaBridge(bridge.win, 15) + + // A stale reply from an earlier request must not resolve this one. + bridge.reply({ source: "amicode", kind: "clipboard", nonce: "someone-elses-nonce", text: "leaked" }) + + expect(await pending).toBe("") // falls through to the timeout instead + }) + + test("resolves empty on a malformed reply body", async () => { + const bridge = fakeFramedWindow() + const pending = readClipboardViaBridge(bridge.win, 15) + const request = bridge.posted[0] + + bridge.reply({ source: "amicode", kind: "clipboard", nonce: request.nonce }) // no text field + + expect(await pending).toBe("") + }) + + test("resolves empty without posting when the app is not framed", async () => { + const posted: unknown[] = [] + const win = { + addEventListener: () => {}, + removeEventListener: () => {}, + postMessage: (message: unknown) => posted.push(message), + } as unknown as Window + // parent === self → not inside a webview iframe + ;(win as unknown as { parent: Window }).parent = win + + expect(await readClipboardViaBridge(win)).toBe("") + expect(posted).toHaveLength(0) + }) +}) diff --git a/packages/app/src/components/prompt-input/clipboard-bridge.ts b/packages/app/src/components/prompt-input/clipboard-bridge.ts new file mode 100644 index 0000000000..ffedf5f4f2 --- /dev/null +++ b/packages/app/src/components/prompt-input/clipboard-bridge.ts @@ -0,0 +1,42 @@ +// ⌘V inside the Amicode chat: the app runs as a cross-origin iframe inside the +// VS Code webview, where native paste and navigator.clipboard deliver no data +// (the webview parent holds no clipboard-read permission to delegate down). The +// extension host CAN read it, so we ask over the amicode postMessage bridge — +// chat_panel.ts reads vscode.env.clipboard and replies with {kind:"clipboard"}. +// Mirrors the profile-input fallback in @opencode-ai/ui's home-cards. +// +// Resolves "" when unframed (plain web/desktop, where native paste already +// works), on a malformed reply, or after `timeoutMs` with no answer — callers +// treat "" as "nothing to insert", so a missing or dead bridge degrades to a +// no-op rather than a hang. + +const BRIDGE_TIMEOUT_MS = 1500 + +export function readClipboardViaBridge(win: Window = window, timeoutMs = BRIDGE_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + // Unframed: native paste works — don't post into the void or wait out the timeout. + if (win.parent === win) { + resolve("") + return + } + + const nonce = Math.random().toString(36).slice(2) + let timer: ReturnType | undefined + + const finish = (text: string) => { + win.removeEventListener("message", onMessage) + if (timer !== undefined) clearTimeout(timer) + resolve(text) + } + + const onMessage = (event: MessageEvent) => { + const data = event.data as { source?: string; kind?: string; nonce?: string; text?: string } | undefined + if (data?.source !== "amicode" || data.kind !== "clipboard" || data.nonce !== nonce) return + finish(typeof data.text === "string" ? data.text : "") + } + + win.addEventListener("message", onMessage) + win.parent.postMessage({ source: "amicode", kind: "clipboard-request", nonce }, "*") + timer = setTimeout(() => finish(""), timeoutMs) + }) +} From aac3b5f8ae930e2768eb2de43dfead434354ac50 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Thu, 9 Jul 2026 20:36:55 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(amicode):=20intercept=20=E2=8C=98V=20ke?= =?UTF-8?q?ydown=20for=20the=20chat=20paste=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass hooked the paste EVENT (onPaste → readClipboardText fallback), but in the VS Code webview iframe the frame has no clipboard-read permission, so the browser dispatches no usable paste event on ⌘V — handlePaste never runs and the fallback never fires. That's why paste still did nothing in the chat. Mirror the fork's working approach (home-cards pasteFallback): intercept the ⌘V keydown directly (only when framed, via inAmicode()), preventDefault, read the OS clipboard over the extension bridge, and insert via addPart. The keystroke fires regardless of clipboard permission, so this bypasses the dead paste event. The onPaste readClipboardText fallback stays for context-menu paste; native web/desktop paste is untouched (inAmicode() gates the keydown path off there). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/app/src/components/prompt-input.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 51b9c09721..0d3baae0c4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -55,9 +55,11 @@ import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +import { inAmicode } from "@/pages/session/use-amicode-commands" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge" +import { normalizePaste } from "./prompt-input/paste" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" import { canNavigateHistoryAtCursor, @@ -1141,6 +1143,24 @@ export const PromptInput: Component = (props) => { }) const handleKeyDown = (event: KeyboardEvent) => { + // Amicode webview: the framed app has no clipboard-read permission, so the + // browser dispatches no usable paste event on ⌘V (unlike plain web/desktop, + // where onPaste handles it). Intercept the keystroke and read the OS + // clipboard over the extension bridge instead (see clipboard-bridge.ts). + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "v" && + inAmicode() + ) { + event.preventDefault() + void readClipboardViaBridge().then((text) => { + if (text) addPart({ type: "text", content: normalizePaste(text), start: 0, end: 0 }) + }) + return + } + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { event.preventDefault() if (store.mode !== "normal") return