Enable clipboard shortcuts in Browser visual mode
Bridge copy, cut, paste, and common edit shortcuts from the Browser modal and canvas screenshot surface into the Playwright runtime while preserving native clipboard behavior for Agent Zero UI fields. Add websocket and runtime clipboard handling with regression coverage for frontend shortcut routing, paste fallback, and viewer input dispatch.
Alessandro committed
May 2, 2026 at 17:21 UTC
ad7925b543001fb41001b8214633dc51fd181620
4 files changed
+660
plugins/_browser/api/ws_browser.py
+16
@@ -249,6 +249,13 @@ class WsBrowser(WsHandler):
249
key=str(data.get("key") or ""),
250
text=str(data.get("text") or ""),
251
)
252
+ elif input_type == "clipboard":
253
+ result = await runtime.call(
254
+ "clipboard",
255
+ browser_id,
256
+ action=str(data.get("action") or ""),
257
+ text=str(data.get("text") or ""),
258
+ )
259
elif input_type == "viewport":
260
result = await runtime.call(
261
"set_viewport",
@@ -271,6 +278,15 @@ class WsBrowser(WsHandler):
278
except Exception as exc:
279
return self._error("INPUT_FAILED", str(exc), data)
280
281
+ if input_type == "clipboard":
282
+ response = {
283
+ "state": result.get("state") if isinstance(result, dict) else result,
284
+ "snapshot": None,
285
+ }
286
+ if isinstance(result, dict):
287
+ response["clipboard"] = result.get("clipboard")
288
+ return response
289
+
290
return {
291
"state": result,
292
"snapshot": await self._snapshot_for_result(runtime, result)
plugins/_browser/helpers/runtime.py
+302
@@ -37,6 +37,243 @@ SCREENCAST_MAX_WIDTH = 4096
37
SCREENCAST_MAX_HEIGHT = 4096
38
VIEWPORT_SIZE_TOLERANCE = 4
39
VIEWPORT_REMOUNT_PAUSE_SECONDS = 0.05
40
+CLIPBOARD_BRIDGE_SCRIPT = r"""
41
+(payload) => {
42
+ const action = String(payload?.action || "").trim().toLowerCase();
43
+ const text = String(payload?.text || "");
44
+ const result = {
45
+ action,
46
+ text: "",
47
+ changed: false,
48
+ default_prevented: false,
49
+ handled: false,
50
+ method: "dom",
51
+ };
52
+ const textInputTypes = new Set([
53
+ "",
54
+ "email",
55
+ "number",
56
+ "password",
57
+ "search",
58
+ "tel",
59
+ "text",
60
+ "url",
61
+ ]);
62
+
63
+ function deepestActiveElement() {
64
+ let active = document.activeElement || document.body || document.documentElement;
65
+ while (active?.shadowRoot?.activeElement) {
66
+ active = active.shadowRoot.activeElement;
67
+ }
68
+ return active || document.body || document.documentElement;
69
+ }
70
+
71
+ function editableTarget(element) {
72
+ if (!element) return null;
73
+ if (isTextControl(element) || element.isContentEditable) return element;
74
+ const closest = element.closest?.("input, textarea, [contenteditable]");
75
+ if (closest && (isTextControl(closest) || closest.isContentEditable)) return closest;
76
+ return element;
77
+ }
78
+
79
+ function isTextControl(element) {
80
+ if (!element) return false;
81
+ const tagName = String(element.tagName || "").toLowerCase();
82
+ if (tagName === "textarea") {
83
+ return !element.disabled && !element.readOnly;
84
+ }
85
+ if (tagName !== "input") return false;
86
+ const type = String(element.type || "text").toLowerCase();
87
+ return textInputTypes.has(type) && !element.disabled && !element.readOnly;
88
+ }
89
+
90
+ function selectedText(element) {
91
+ if (isTextControl(element)) {
92
+ try {
93
+ const start = Number(element.selectionStart);
94
+ const end = Number(element.selectionEnd);
95
+ if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
96
+ return String(element.value || "").slice(start, end);
97
+ }
98
+ } catch {}
99
+ return "";
100
+ }
101
+ const selection = globalThis.getSelection?.();
102
+ return selection ? String(selection.toString() || "") : "";
103
+ }
104
+
105
+ function makeClipboardData(seedText = "") {
106
+ let transfer = null;
107
+ try {
108
+ transfer = new DataTransfer();
109
+ } catch {}
110
+ if (transfer && seedText) {
111
+ transfer.setData("text/plain", seedText);
112
+ transfer.setData("text", seedText);
113
+ }
114
+ return transfer;
115
+ }
116
+
117
+ function clipboardDataText(transfer) {
118
+ if (!transfer) return "";
119
+ return String(transfer.getData("text/plain") || transfer.getData("text") || "");
120
+ }
121
+
122
+ function makeClipboardEvent(type, transfer) {
123
+ let event = null;
124
+ try {
125
+ event = new ClipboardEvent(type, {
126
+ bubbles: true,
127
+ cancelable: true,
128
+ clipboardData: transfer,
129
+ });
130
+ } catch {}
131
+ if (!event) {
132
+ event = new Event(type, { bubbles: true, cancelable: true });
133
+ }
134
+ if (transfer && !event.clipboardData) {
135
+ try {
136
+ Object.defineProperty(event, "clipboardData", { value: transfer });
137
+ } catch {}
138
+ }
139
+ return event;
140
+ }
141
+
142
+ function dispatchClipboardEvent(target, type, seedText = "") {
143
+ const transfer = makeClipboardData(seedText);
144
+ const event = makeClipboardEvent(type, transfer);
145
+ (target || document.body || document.documentElement).dispatchEvent(event);
146
+ return {
147
+ defaultPrevented: Boolean(event.defaultPrevented),
148
+ text: clipboardDataText(event.clipboardData || transfer),
149
+ };
150
+ }
151
+
152
+ function dispatchInputEvent(element, type, inputType, data = null) {
153
+ let event = null;
154
+ try {
155
+ event = new InputEvent(type, {
156
+ bubbles: true,
157
+ cancelable: type === "beforeinput",
158
+ inputType,
159
+ data,
160
+ });
161
+ } catch {}
162
+ if (!event) {
163
+ event = new Event(type, {
164
+ bubbles: true,
165
+ cancelable: type === "beforeinput",
166
+ });
167
+ }
168
+ return element.dispatchEvent(event);
169
+ }
170
+
171
+ function insertIntoTextControl(element, value) {
172
+ if (!isTextControl(element)) return false;
173
+ let start = 0;
174
+ let end = 0;
175
+ try {
176
+ start = Number(element.selectionStart);
177
+ end = Number(element.selectionEnd);
178
+ } catch {
179
+ return false;
180
+ }
181
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
182
+ if (!dispatchInputEvent(element, "beforeinput", "insertFromPaste", value)) {
183
+ return false;
184
+ }
185
+ element.setRangeText(value, start, end, "end");
186
+ dispatchInputEvent(element, "input", "insertFromPaste", value);
187
+ return true;
188
+ }
189
+
190
+ function insertIntoContentEditable(element, value) {
191
+ if (!element?.isContentEditable) return false;
192
+ if (!dispatchInputEvent(element, "beforeinput", "insertFromPaste", value)) {
193
+ return false;
194
+ }
195
+ const selection = globalThis.getSelection?.();
196
+ if (!selection || selection.rangeCount === 0) return false;
197
+ try {
198
+ if (document.queryCommandSupported?.("insertText") && document.execCommand("insertText", false, value)) {
199
+ return true;
200
+ }
201
+ } catch {}
202
+ const range = selection.getRangeAt(0);
203
+ range.deleteContents();
204
+ const node = document.createTextNode(value);
205
+ range.insertNode(node);
206
+ range.setStartAfter(node);
207
+ range.collapse(true);
208
+ selection.removeAllRanges();
209
+ selection.addRange(range);
210
+ dispatchInputEvent(element, "input", "insertFromPaste", value);
211
+ return true;
212
+ }
213
+
214
+ function insertText(element, value) {
215
+ return insertIntoTextControl(element, value) || insertIntoContentEditable(element, value);
216
+ }
217
+
218
+ function removeSelectedText(element) {
219
+ if (isTextControl(element)) {
220
+ let start = 0;
221
+ let end = 0;
222
+ try {
223
+ start = Number(element.selectionStart);
224
+ end = Number(element.selectionEnd);
225
+ } catch {
226
+ return false;
227
+ }
228
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return false;
229
+ if (!dispatchInputEvent(element, "beforeinput", "deleteByCut", null)) {
230
+ return false;
231
+ }
232
+ element.setRangeText("", start, end, "start");
233
+ dispatchInputEvent(element, "input", "deleteByCut", null);
234
+ return true;
235
+ }
236
+ if (!element?.isContentEditable) return false;
237
+ const selection = globalThis.getSelection?.();
238
+ if (!selection || selection.rangeCount === 0 || !String(selection.toString() || "")) {
239
+ return false;
240
+ }
241
+ if (!dispatchInputEvent(element, "beforeinput", "deleteByCut", null)) {
242
+ return false;
243
+ }
244
+ selection.deleteFromDocument();
245
+ dispatchInputEvent(element, "input", "deleteByCut", null);
246
+ return true;
247
+ }
248
+
249
+ const target = editableTarget(deepestActiveElement());
250
+ if (action === "paste") {
251
+ const event = dispatchClipboardEvent(target, "paste", text);
252
+ result.default_prevented = event.defaultPrevented;
253
+ result.handled = true;
254
+ result.text = text;
255
+ if (!event.defaultPrevented) {
256
+ result.changed = insertText(target, text);
257
+ }
258
+ return result;
259
+ }
260
+
261
+ if (action === "copy" || action === "cut") {
262
+ const selectionText = selectedText(target);
263
+ const event = dispatchClipboardEvent(target, action, selectionText);
264
+ result.default_prevented = event.defaultPrevented;
265
+ result.text = event.text || selectionText;
266
+ result.handled = Boolean(result.text || event.defaultPrevented);
267
+ if (action === "cut" && result.text && !event.defaultPrevented) {
268
+ result.changed = removeSelectedText(target);
269
+ }
270
+ return result;
271
+ }
272
+
273
+ result.error = `Unsupported clipboard action: ${action}`;
274
+ return result;
275
+}
276
+"""
277
278
_SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
279
_URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
@@ -956,6 +1193,61 @@ class _BrowserRuntimeCore:
1193
) -> dict[str, Any]:
1194
return await self._reference_action("typeSubmit", browser_id, reference_id, text)
1195
1196
+ async def clipboard(
1197
+ self,
1198
+ browser_id: int | str | None,
1199
+ *,
1200
+ action: str,
1201
+ text: str = "",
1202
+ ) -> dict[str, Any]:
1203
+ await self.ensure_started()
1204
+ resolved_id = self._resolve_browser_id(browser_id)
1205
+ page = self._page(resolved_id)
1206
+ normalized_action = str(action or "").strip().lower()
1207
+ if normalized_action not in {"copy", "cut", "paste"}:
1208
+ raise ValueError(f"Unsupported clipboard action: {normalized_action}")
1209
+
1210
+ clipboard_result: dict[str, Any]
1211
+ try:
1212
+ clipboard_result = await page.evaluate(
1213
+ CLIPBOARD_BRIDGE_SCRIPT,
1214
+ {
1215
+ "action": normalized_action,
1216
+ "text": str(text or ""),
1217
+ },
1218
+ ) or {}
1219
+ except Exception as exc:
1220
+ clipboard_result = {
1221
+ "action": normalized_action,
1222
+ "text": "",
1223
+ "changed": False,
1224
+ "default_prevented": False,
1225
+ "handled": False,
1226
+ "error": str(exc),
1227
+ }
1228
+
1229
+ if (
1230
+ normalized_action == "paste"
1231
+ and text
1232
+ and not clipboard_result.get("changed")
1233
+ and not clipboard_result.get("default_prevented")
1234
+ ):
1235
+ if await self._insert_clipboard_text(page, str(text)):
1236
+ clipboard_result["changed"] = True
1237
+ clipboard_result["method"] = "keyboard.insert_text"
1238
+ elif normalized_action in {"copy", "cut"} and not clipboard_result.get("text"):
1239
+ with contextlib.suppress(Exception):
1240
+ shortcut = "Control+C" if normalized_action == "copy" else "Control+X"
1241
+ await page.keyboard.press(shortcut)
1242
+ clipboard_result["keyboard_shortcut"] = True
1243
+
1244
+ await self._settle(page, short=True)
1245
+ self._maybe_promote(resolved_id)
1246
+ return {
1247
+ "state": await self._state(resolved_id),
1248
+ "clipboard": clipboard_result,
1249
+ }
1250
+
1251
async def close_browser(self, browser_id: int | str | None = None) -> dict[str, Any]:
1252
await self.ensure_started()
1253
resolved_id = self._resolve_browser_id(browser_id)
@@ -1175,6 +1467,16 @@ class _BrowserRuntimeCore:
1467
self._maybe_promote(resolved_id)
1468
return await self._state(resolved_id)
1469
1470
+ async def _insert_clipboard_text(self, page: Any, text: str) -> bool:
1471
+ if not text:
1472
+ return False
1473
+ insert_text = getattr(page.keyboard, "insert_text", None)
1474
+ if callable(insert_text):
1475
+ await insert_text(str(text))
1476
+ else:
1477
+ await page.keyboard.type(str(text))
1478
+ return True
1479
+
1480
async def close(self, delete_profile: bool = False) -> None:
1481
self._closing = True
1482
for waiter in self._pending_popups:
plugins/_browser/webui/browser-store.js
+165
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2
import { callJsonApi } from "/js/api.js";
3
import { getNamespacedClient } from "/js/websocket.js";
4
import { getContext, setContext } from "/index.js";
5
+import { copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";
6
import { store as chatInputStore } from "/components/chat/input/input-store.js";
7
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
8
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
@@ -24,6 +25,8 @@ const ANNOTATION_DRAG_THRESHOLD = 6;
25
const ANNOTATION_MAX_COMMENTS = 24;
26
const ANNOTATION_DOM_LIMIT = 1200;
27
const ANNOTATION_TRAY_MARGIN = 10;
28
+const BROWSER_VISUAL_SHORTCUT_KEYS = new Set(["a", "c", "insert", "v", "x", "y", "z"]);
29
+const LOCAL_EDITABLE_SELECTOR = "input, textarea, select, [contenteditable]";
30
31
function makeViewerToken() {
32
return globalThis.crypto?.randomUUID?.()
@@ -54,6 +57,21 @@ function normalizeBool(value, fallback = true) {
57
return fallback;
58
}
59
60
+function elementFromTarget(target) {
61
+ if (!target) return null;
62
+ if (target.nodeType === 1) return target;
63
+ return target.parentElement || null;
64
+}
65
+
66
+function isLocalEditableTarget(target) {
67
+ const element = elementFromTarget(target);
68
+ const editable = element?.closest?.(LOCAL_EDITABLE_SELECTOR);
69
+ if (!editable) return false;
70
+ if (editable.matches?.("input, textarea, select")) return true;
71
+ const value = String(editable.getAttribute?.("contenteditable") || "").trim().toLowerCase();
72
+ return ["", "true", "plaintext-only"].includes(value);
73
+}
74
+
75
function nextAnimationFrame() {
76
return new Promise((resolve) => {
77
const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
@@ -169,6 +187,7 @@ const model = {
187
_closingBrowserIds: {},
188
_configLoadedAt: 0,
189
_configRefreshPromise: null,
190
+ _clipboardFallbackText: "",
191
192
async refreshStatus() {
193
this.status = await callJsonApi("/plugins/_browser/status", {});
@@ -1546,9 +1565,78 @@ const model = {
1565
return;
1566
}
1567
1568
+ if (this.handleVisualBrowserShortcut(event)) {
1569
+ return;
1570
+ }
1571
+
1572
void this.sendKey(event);
1573
},
1574
1575
+ handleVisualBrowserShortcut(event) {
1576
+ const shortcut = this.visualBrowserShortcut(event);
1577
+ if (!shortcut) return false;
1578
+ event.preventDefault();
1579
+ event.stopPropagation?.();
1580
+
1581
+ if (shortcut.action === "paste") {
1582
+ void this.pasteHostClipboardToBrowser();
1583
+ return true;
1584
+ }
1585
+ if (shortcut.action === "copy" || shortcut.action === "cut") {
1586
+ void this.copyBrowserClipboardToHost(shortcut.action);
1587
+ return true;
1588
+ }
1589
+ if (shortcut.key) {
1590
+ void this.sendShortcut(shortcut.key);
1591
+ return true;
1592
+ }
1593
+ return false;
1594
+ },
1595
+
1596
+ visualBrowserShortcut(event) {
1597
+ if (!this.shouldHandleVisualBrowserShortcut(event)) return null;
1598
+ const key = String(event?.key || "").toLowerCase();
1599
+ const primary = Boolean(event?.ctrlKey || event?.metaKey);
1600
+ const shift = Boolean(event?.shiftKey);
1601
+
1602
+ if (!primary && shift && key === "insert") {
1603
+ return { action: "paste" };
1604
+ }
1605
+ if (!primary || event?.altKey) return null;
1606
+
1607
+ if (key === "v") return { action: "paste" };
1608
+ if (!shift && (key === "c" || key === "insert")) return { action: "copy" };
1609
+ if (!shift && key === "x") return { action: "cut" };
1610
+ if (!shift && key === "a") return { key: "Control+A" };
1611
+ if (key === "z") return { key: shift ? "Control+Shift+Z" : "Control+Z" };
1612
+ if (!shift && key === "y") return { key: "Control+Y" };
1613
+ return null;
1614
+ },
1615
+
1616
+ shouldHandleVisualBrowserShortcut(event) {
1617
+ if (!this._surfaceMounted || !this.activeBrowserId || this.annotating) return false;
1618
+ if (isLocalEditableTarget(event?.target)) return false;
1619
+ const key = String(event?.key || "").toLowerCase();
1620
+ if (!BROWSER_VISUAL_SHORTCUT_KEYS.has(key)) return false;
1621
+ return Boolean(this.visualBrowserStageForEvent(event));
1622
+ },
1623
+
1624
+ visualBrowserStageForEvent(event) {
1625
+ const element = elementFromTarget(event?.target);
1626
+ const blockingUi = element?.closest?.(
1627
+ ".browser-toolbar, .browser-meta, .browser-extension-dropdown, .browser-annotation-popover, .browser-annotation-tray, button, a",
1628
+ );
1629
+ if (blockingUi) return null;
1630
+
1631
+ const stage = element?.closest?.(".browser-stage");
1632
+ if (stage?.closest?.(".browser-panel")) return stage;
1633
+
1634
+ const activeElement = globalThis.document?.activeElement;
1635
+ const activeStage = activeElement?.closest?.(".browser-stage");
1636
+ if (activeStage?.closest?.(".browser-panel")) return activeStage;
1637
+ return null;
1638
+ },
1639
+
1640
handleStageWheel(event) {
1641
if (this.annotating) return;
1642
void this.sendWheel(event);
@@ -2184,6 +2272,83 @@ const model = {
2272
});
2273
},
2274
2275
+ async sendShortcut(key) {
2276
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2277
+ if (!contextId || !this.activeBrowserId || !key) return;
2278
+ await websocket.emit("browser_viewer_input", {
2279
+ context_id: contextId,
2280
+ browser_id: this.activeBrowserId,
2281
+ viewer_id: this._viewerToken,
2282
+ input_type: "keyboard",
2283
+ key,
2284
+ text: "",
2285
+ });
2286
+ },
2287
+
2288
+ async pasteHostClipboardToBrowser() {
2289
+ try {
2290
+ const text = await this.readHostClipboardText();
2291
+ if (!text) return;
2292
+ await this.sendClipboard("paste", text);
2293
+ } catch (error) {
2294
+ this.error = "Browser paste needs clipboard permission in this tab.";
2295
+ globalThis.justToast?.(this.error, "warning", 2200, "browser-clipboard");
2296
+ console.warn("Browser clipboard paste failed", error);
2297
+ }
2298
+ },
2299
+
2300
+ async copyBrowserClipboardToHost(action = "copy") {
2301
+ try {
2302
+ const clipboard = await this.sendClipboard(action);
2303
+ const text = String(clipboard?.text || clipboard?.clipboard_text || "");
2304
+ if (!text) return;
2305
+ await copyToClipboard(text);
2306
+ this._clipboardFallbackText = text;
2307
+ const message = action === "cut" ? "Cut from Browser" : "Copied from Browser";
2308
+ globalThis.justToast?.(message, "success", 1200, "browser-clipboard");
2309
+ } catch (error) {
2310
+ this.error = action === "cut"
2311
+ ? "Browser cut failed."
2312
+ : "Browser copy failed.";
2313
+ globalThis.justToast?.(this.error, "warning", 1800, "browser-clipboard");
2314
+ console.warn("Browser clipboard copy failed", error);
2315
+ }
2316
+ },
2317
+
2318
+ async readHostClipboardText() {
2319
+ const clipboard = globalThis.navigator?.clipboard;
2320
+ if (clipboard?.readText && globalThis.isSecureContext) {
2321
+ try {
2322
+ return await clipboard.readText();
2323
+ } catch (error) {
2324
+ if (this._clipboardFallbackText) return this._clipboardFallbackText;
2325
+ throw error;
2326
+ }
2327
+ }
2328
+ return this._clipboardFallbackText || "";
2329
+ },
2330
+
2331
+ async sendClipboard(action = "copy", text = "") {
2332
+ const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2333
+ if (!contextId || !this.activeBrowserId) return {};
2334
+ const response = await websocket.request(
2335
+ "browser_viewer_input",
2336
+ {
2337
+ context_id: contextId,
2338
+ browser_id: this.activeBrowserId,
2339
+ viewer_id: this._viewerToken,
2340
+ input_type: "clipboard",
2341
+ action,
2342
+ text,
2343
+ },
2344
+ { timeoutMs: 10000 },
2345
+ );
2346
+ const data = firstOk(response);
2347
+ this.applyActiveFrameState(data.state);
2348
+ this.applySnapshot(data.snapshot);
2349
+ return data.clipboard || {};
2350
+ },
2351
+
2352
async cleanup() {
2353
if (this._surfaceHandoff) {
2354
this.releaseSurfaceBindings();
tests/test_browser_agent_regressions.py
+177
@@ -869,6 +869,41 @@ def test_browser_annotate_mode_ui_and_prompt_hooks():
869
assert "value=\\\"[redacted]\\\"" in browser_store
870
871
872
+def test_browser_visual_mode_bridges_clipboard_shortcuts():
873
+ browser_store = (
874
+ PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
875
+ ).read_text(encoding="utf-8")
876
+ runtime = (
877
+ PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
878
+ ).read_text(encoding="utf-8")
879
+ ws_browser = (
880
+ PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py"
881
+ ).read_text(encoding="utf-8")
882
+
883
+ assert 'import { copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";' in browser_store
884
+ assert "BROWSER_VISUAL_SHORTCUT_KEYS" in browser_store
885
+ assert "handleVisualBrowserShortcut(event)" in browser_store
886
+ assert "visualBrowserStageForEvent(event)" in browser_store
887
+ assert "isLocalEditableTarget(event?.target)" in browser_store
888
+ assert 'return { action: "paste" };' in browser_store
889
+ assert 'return { action: "copy" };' in browser_store
890
+ assert 'return { action: "cut" };' in browser_store
891
+ assert 'return { key: "Control+A" };' in browser_store
892
+ assert 'return { key: shift ? "Control+Shift+Z" : "Control+Z" };' in browser_store
893
+ assert 'input_type: "clipboard"' in browser_store
894
+ assert "pasteHostClipboardToBrowser()" in browser_store
895
+ assert "copyBrowserClipboardToHost" in browser_store
896
+ assert "Browser paste needs clipboard permission in this tab." in browser_store
897
+
898
+ assert "CLIPBOARD_BRIDGE_SCRIPT" in runtime
899
+ assert "async def clipboard" in runtime
900
+ assert "insertFromPaste" in runtime
901
+ assert "deleteByCut" in runtime
902
+ assert "keyboard.insert_text" in runtime
903
+ assert 'input_type == "clipboard"' in ws_browser
904
+ assert 'runtime.call(\n "clipboard"' in ws_browser
905
+
906
+
907
def test_browser_runtime_and_content_helper_expose_annotation_target():
908
runtime = (
909
PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
@@ -1458,6 +1493,100 @@ async def test_browser_runtime_remounts_initial_changed_viewport():
1493
assert settled == [True]
1494
1495
1496
+@pytest.mark.anyio
1497
+async def test_browser_runtime_clipboard_paste_uses_dom_bridge():
1498
+ eval_payloads = []
1499
+ settled = []
1500
+
1501
+ class FakeKeyboard:
1502
+ def __init__(self):
1503
+ self.inserted = []
1504
+
1505
+ async def insert_text(self, text):
1506
+ self.inserted.append(text)
1507
+
1508
+ class FakePage:
1509
+ url = "about:blank"
1510
+
1511
+ def __init__(self):
1512
+ self.keyboard = FakeKeyboard()
1513
+
1514
+ async def evaluate(self, script, payload=None):
1515
+ if payload is not None:
1516
+ eval_payloads.append((script, payload))
1517
+ return {
1518
+ "action": "paste",
1519
+ "text": payload["text"],
1520
+ "changed": True,
1521
+ "default_prevented": False,
1522
+ }
1523
+ return 1
1524
+
1525
+ async def title(self):
1526
+ return "Blank"
1527
+
1528
+ page = FakePage()
1529
+ core = _BrowserRuntimeCore("ctx")
1530
+ core.context = object()
1531
+ core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=page)
1532
+
1533
+ async def fake_settle(_page, short=False):
1534
+ settled.append(short)
1535
+
1536
+ core._settle = fake_settle
1537
+
1538
+ result = await core.clipboard(7, action="paste", text="hello")
1539
+
1540
+ assert result["state"]["id"] == 7
1541
+ assert result["clipboard"]["changed"] is True
1542
+ assert result["clipboard"]["text"] == "hello"
1543
+ assert eval_payloads[0][1] == {"action": "paste", "text": "hello"}
1544
+ assert "insertFromPaste" in eval_payloads[0][0]
1545
+ assert page.keyboard.inserted == []
1546
+ assert settled == [True]
1547
+
1548
+
1549
+@pytest.mark.anyio
1550
+async def test_browser_runtime_clipboard_paste_falls_back_to_keyboard_insert_text():
1551
+ class FakeKeyboard:
1552
+ def __init__(self):
1553
+ self.inserted = []
1554
+
1555
+ async def insert_text(self, text):
1556
+ self.inserted.append(text)
1557
+
1558
+ class FakePage:
1559
+ url = "about:blank"
1560
+
1561
+ def __init__(self):
1562
+ self.keyboard = FakeKeyboard()
1563
+
1564
+ async def evaluate(self, script, payload=None):
1565
+ if payload is not None:
1566
+ return {
1567
+ "action": "paste",
1568
+ "text": payload["text"],
1569
+ "changed": False,
1570
+ "default_prevented": False,
1571
+ }
1572
+ return 1
1573
+
1574
+ async def title(self):
1575
+ return "Blank"
1576
+
1577
+ page = FakePage()
1578
+ core = _BrowserRuntimeCore("ctx")
1579
+ core.context = object()
1580
+ core.pages[7] = browser_runtime_module.BrowserPage(id=7, page=page)
1581
+ core._settle = lambda _page, short=False: asyncio.sleep(0)
1582
+
1583
+ result = await core.clipboard(7, action="paste", text="hello")
1584
+
1585
+ assert result["clipboard"]["changed"] is True
1586
+ assert result["clipboard"]["method"] == "keyboard.insert_text"
1587
+ assert page.keyboard.inserted == ["hello"]
1588
+
1589
+
1590
@pytest.mark.anyio
1591
async def test_browser_viewer_wheel_input_dispatches_scroll(monkeypatch):
1592
calls = []
@@ -1501,6 +1630,54 @@ async def test_browser_viewer_wheel_input_dispatches_scroll(monkeypatch):
1630
assert calls == [("wheel", (3, 320.0, 480.0, 0.0, 640.0), {})]
1631
1632
1633
+@pytest.mark.anyio
1634
+async def test_browser_viewer_clipboard_input_dispatches_runtime(monkeypatch):
1635
+ calls = []
1636
+ clipboard = {"action": "paste", "text": "hello", "changed": True}
1637
+
1638
+ class FakeRuntime:
1639
+ async def call(self, method, *args, **kwargs):
1640
+ calls.append((method, args, kwargs))
1641
+ return {
1642
+ "state": {"id": args[0], "currentUrl": "about:blank"},
1643
+ "clipboard": clipboard,
1644
+ }
1645
+
1646
+ async def fake_get_runtime(context_id, create=True):
1647
+ assert context_id == "ctx"
1648
+ assert create is False
1649
+ return FakeRuntime()
1650
+
1651
+ monkeypatch.setattr(ws_browser_module, "get_runtime", fake_get_runtime)
1652
+
1653
+ handler = ws_browser_module.WsBrowser(
1654
+ SimpleNamespace(),
1655
+ threading.RLock(),
1656
+ manager=None,
1657
+ )
1658
+
1659
+ result = await handler.process(
1660
+ "browser_viewer_input",
1661
+ {
1662
+ "context_id": "ctx",
1663
+ "browser_id": 3,
1664
+ "input_type": "clipboard",
1665
+ "action": "paste",
1666
+ "text": "hello",
1667
+ },
1668
+ "sid-1",
1669
+ )
1670
+
1671
+ assert result == {
1672
+ "state": {"id": 3, "currentUrl": "about:blank"},
1673
+ "clipboard": clipboard,
1674
+ "snapshot": None,
1675
+ }
1676
+ assert calls == [
1677
+ ("clipboard", (3,), {"action": "paste", "text": "hello"})
1678
+ ]
1679
+
1680
+
1681
@pytest.mark.anyio
1682
async def test_browser_viewer_annotation_dispatches_runtime(monkeypatch):
1683
calls = []