diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 184c1c8aa9..0d3baae0c4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -55,8 +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, @@ -1087,6 +1090,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 = () => ( @@ -1138,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 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) + }) +}