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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"

// harmoniqs/amicode#261 — paste inserted the same text twice in the webview.
//
// Two mod+V handlers used to be live inside the framed app:
// 1. utils/global-clipboard.ts — window-level, CAPTURE phase.
// 2. The composer's own keydown handler (v1 handleKeyDown / v2 onKeyDown),
// which preventDefault'd but never stopPropagation'd, so BOTH bridge-
// inserted the same text. An opt-out marker (data-amc-clipboard="self")
// on the composer editors was tried, but the amicode fork hard-locks the
// v2 layout and the marker contract proved too fragile to maintain across
// the two composers.
//
// The current design removes the composers' keydown interception entirely:
// the window-level fallback is the SOLE ⌘V path in the webview (single
// insert), and the native paste event path (composer onPaste → handlePaste)
// remains for chords the fallback doesn't own (⌘⇧V). Image/screenshot paste
// over ⌘V is knowingly sacrificed — the fallback reads text only.
//
// This is a SOURCE assertion rather than a rendered-DOM one because the app has
// no component-render harness (no @solidjs/testing-library) — the same reason
// global-clipboard.test.ts exercises the fallback against a synthetic element
// it builds itself, which is exactly the gap that let #261 ship. Replace this
// with a render assertion the day a harness lands.
const source = readFileSync(join(import.meta.dir, "prompt-input.tsx"), "utf8")
const v2Interaction = readFileSync(
join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/interaction.ts"),
"utf8",
)
const v2Attachments = readFileSync(
join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/attachments.ts"),
"utf8",
)
const v2Editor = readFileSync(
join(import.meta.dir, "../../../session-ui/src/v2/components/prompt-input/index.tsx"),
"utf8",
)
const globalClipboard = readFileSync(join(import.meta.dir, "../utils/global-clipboard.ts"), "utf8")

describe("single ⌘V path in the webview (amicode#261)", () => {
test("the v1 composer no longer intercepts ⌘V at keydown", () => {
expect(source).not.toContain("inAmicode")
expect(source).not.toContain('event.key.toLowerCase() === "v"')
})

test("the v2 composer no longer intercepts ⌘V at keydown", () => {
expect(v2Interaction).not.toContain('event.key.toLowerCase() === "v"')
expect(v2Interaction).not.toContain("handleFramedPaste")
expect(v2Attachments).not.toContain("handleFramedPaste")
})

test("neither composer editor carries the opt-out marker", () => {
// The marker contract is gone: the fallback owns ⌘V everywhere. A marker
// here would silently re-orphan the composer (nothing would paste on ⌘V).
expect(source).not.toContain('data-amc-clipboard="self"')
expect(v2Editor).not.toContain('data-amc-clipboard="self"')
})

test("the window-level fallback still owns the ⌘V branch for editables", () => {
expect(globalClipboard).toContain('key !== "v"')
expect(globalClipboard).toContain("installGlobalClipboardFallback")
expect(globalClipboard).toContain("readClipboardViaBridge")
})

test("the composers still wire the native paste path with bridge fallbacks", () => {
// ⌘⇧V (and any non-prevented paste event) flows: onPaste → handlePaste,
// which falls back to the host-clipboard bridges when the event carries
// nothing readable.
expect(source).toContain("onPaste={handlePaste}")
expect(v2Interaction).toContain("handlePaste")
expect(source).toContain("readClipboardViaBridge")
})
})
12 changes: 10 additions & 2 deletions packages/app/src/components/prompt-input-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { ReportBugButton } from "@/components/report-bug-button"
Expand All @@ -29,6 +29,7 @@ import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { bugReportEnabled } from "@/utils/amicode-bug-report"
import { setClipboardImageHandler } from "@/utils/global-clipboard"
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
Expand Down Expand Up @@ -386,7 +387,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
// the extension host where the framed app has no clipboard permission.
// Bridge first (self-gates to null unframed), platform as fallback.
readClipboardImage: async () => (await readClipboardImageViaBridge()) ?? (await platform.readClipboardImage?.()) ?? null,
// Text side of the same bridge — feeds session-ui's handleFramedPaste.
// Text side of the same bridge — feeds session-ui's handlePaste framed
// text fallback when the paste event carries nothing readable.
readClipboardText: () => readClipboardViaBridge(),
getPathForFile: platform.getPathForFile,
store: platform.draftStore?.putBlob,
Expand Down Expand Up @@ -419,6 +421,12 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })

// Framed webview: the window-level fallback (global-clipboard.ts) is the
// sole ⌘V owner. When the clipboard carries no text it offers the media to
// this slot, landing it in the composer's attachment pipeline.
setClipboardImageHandler((file) => controller.addAttachments([file]))
onCleanup(() => setClipboardImageHandler(undefined))

command.register("prompt-input", () => [
{
id: "file.attach",
Expand Down
29 changes: 1 addition & 28 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,9 @@ import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
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, readClipboardImageViaBridge } from "./prompt-input/clipboard-bridge"
import { normalizePaste } from "./prompt-input/paste"
import { readClipboardViaBridge } from "./prompt-input/clipboard-bridge"
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
import {
canNavigateHistoryAtCursor,
Expand Down Expand Up @@ -1218,31 +1216,6 @@ export const PromptInput: Component<PromptInputProps> = (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).
// Image first (screenshots), then text — mirrors handlePaste's precedence.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "v" &&
inAmicode()
) {
event.preventDefault()
void (async () => {
const image = await readClipboardImageViaBridge()
if (image) {
await addAttachment(image)
return
}
const text = await readClipboardViaBridge()
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
Expand Down
63 changes: 43 additions & 20 deletions packages/app/src/utils/global-clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
// Framed-app editing fallback, generalized from the prompt input's bridge.
// Inside the VS Code webview iframe, native editing shortcuts never fire:
// paste never reaches the DOM, copy never writes the OS clipboard, and
// select-all / undo / redo are suppressed by the Electron platform layer
// (see prompt-input/clipboard-bridge.ts for the full why). This module
// intercepts mod+V/C/X/A/Z/Y at the window's capture phase and implements
// them explicitly in JS — clipboard ops route over the extension-host bridge,
// and select-all / undo / redo call the DOM APIs directly. Unframed
// (plain web/desktop), it does nothing — native editing behavior stands.

import { readClipboardViaBridge, writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge"

// Elements that carry their own bridged paste (the prompt input's ⌘V handler,
// the profile fields' pasteFallback) mark themselves so the fallback doesn't
// double-insert. The marker owns PASTE only: nothing element-local handles
// copy/cut, so ⌘C/⌘X still mirror to the OS clipboard even inside marked
// subtrees — otherwise copying from the prompt would paste stale content.
// Framed-app clipboard fallback, generalized from the prompt input's bridge.
// Inside the VS Code webview iframe, native paste never fires and native
// copy never reaches the OS clipboard (see prompt-input/clipboard-bridge.ts
// for the full why) — so every editable silently ignores ⌘V and poisons the
// next paste on ⌘C. This module intercepts mod+V/C/X at the window's capture
// phase and routes them over the existing extension-host bridge. It is the
// SOLE ⌘V path in the webview: the prompt composers (v1 and v2) no longer
// intercept the keystroke themselves, so the text lands exactly once.
// Unframed (plain web/desktop), it does nothing — native clipboard behavior
// stands.

import {
readClipboardImageViaBridge,
readClipboardViaBridge,
writeClipboardViaBridge,
} from "@/components/prompt-input/clipboard-bridge"

// Single-slot media hook: a paste that carries no text is offered to the
// registered consumer (the v2 composer's attachment pipeline) as an image.
// Deliberately one slot — a list would invite two owners of one gesture.
let clipboardImageHandler: ((file: File) => void) | undefined

export function setClipboardImageHandler(handler?: (file: File) => void): void {
clipboardImageHandler = handler
}

// Elements that carry their own bridged paste (the profile fields'
// pasteFallback) mark themselves so the fallback doesn't double-insert. The
// marker owns PASTE only: nothing element-local handles copy/cut, so ⌘C/⌘X
// still mirror to the OS clipboard even inside marked subtrees — otherwise
// copying from the prompt would paste stale content.
export const CLIPBOARD_SELF_SELECTOR = '[data-amc-clipboard="self"]'

type FormField = HTMLInputElement | HTMLTextAreaElement
Expand Down Expand Up @@ -194,9 +208,18 @@ export function installGlobalClipboardFallback(win: Window = window): () => void
// Native paste never fires in-frame, so preventDefault loses nothing;
// an empty or dead bridge reply degrades to a no-op (see clipboard-bridge).
event.preventDefault()
void readClipboardViaBridge(win).then((text) => {
if (!text) return
insertTextAtSelection(target, text)
void readClipboardViaBridge(win).then(async (text) => {
if (text) {
insertTextAtSelection(target, text)
return
}
// No text on the clipboard: offer media. Mirrors the composer's own
// handlePaste precedence (image only when there is no plain text), so
// text pastes keep their exact single-value sequence with no extra
// round-trip.
if (!clipboardImageHandler) return
const file = await readClipboardImageViaBridge(win)
if (file) clipboardImageHandler(file)
})
return
}
Expand Down
21 changes: 0 additions & 21 deletions packages/session-ui/src/v2/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,26 +199,6 @@ export function createPromptInputV2Attachments(
if (files) await addAttachments(Array.from(files))
}

// Amicode webview ⌘V: a framed host whose paste event never fires at all
// (the VS Code webview grants no clipboard-read to the iframe), so the paste
// flow above never starts. Read the host clipboard through the wired bridge
// hooks instead — image first (screenshots), then text, same precedence as
// handlePaste. Both hooks self-gate to empty outside the webview.
const handleFramedPaste = async () => {
const target = capture()
if (!target) return
if (input.readClipboardImage) {
const file = await input.readClipboardImage()
if (file && (await add(file, true, target, true))) return
}
const plain = input.readClipboardText ? await input.readClipboardText() : ""
if (!plain) return
const text = plain.includes("\r") ? plain.replace(/\r\n?/g, "\n") : plain
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return
input.focusEditor()
input.addPart({ type: "text", content: text, start: 0, end: 0 })
}

onMount(() => {
makeEventListener(document, "dragover", (event) => {
if (input.isDialogActive()) return
Expand All @@ -235,7 +215,6 @@ export function createPromptInputV2Attachments(
return {
addAttachments,
handlePaste,
handleFramedPaste,
handleDrop,
pick(fallback: () => void) {
if (!input.picker) {
Expand Down
18 changes: 0 additions & 18 deletions packages/session-ui/src/v2/components/prompt-input/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,24 +186,6 @@ export function createPromptInputV2Controller(input: {
}

const onKeyDown = (event: KeyboardEvent) => {
// Amicode webview ⌘V (amicode patch #11 parity with the legacy composer):
// the framed app has no clipboard-read permission, so the browser dispatches
// no usable paste event on ⌘V. Intercept the keystroke and read the host
// clipboard over the wired bridge hooks instead (attachments.handleFramedPaste).
// Framed contexts only — plain web/desktop keeps the native paste event.
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "v" &&
typeof window !== "undefined" &&
window.parent !== window &&
attachments
) {
event.preventDefault()
void attachments.handleFramedPaste()
return true
}
if (
state.mode === "normal" &&
(event.metaKey || event.ctrlKey) &&
Expand Down
Loading