Preserve fallback Alt shortcuts

Only forward Alt-modified text from AltGraph or macOS Option input. Keep ordinary Windows and Linux Alt shortcuts from becoming literal characters, with focused regression coverage.

Alessandro committed Aug 27, 2026 at 13:04 UTC efb8401e66ba48df7114489573fb736d3cf8f4f3
3 files changed +41 -3
plugins/_browser/AGENTS.md
+1 -1
@@ -48,7 +48,7 @@
48 - Annotation voice input reuses Whisper STT's configured draft/send delivery mode and shared microphone state.
49 - Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
50 - Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.
51 -- Browser keyboard layout settings (`keyboard_layout`/`keyboard_variant`, e.g. `de`/`mac`) apply the configured XKB layout to the private browser display with setxkbmap and pin it on the Xpra shadow server so non-US keyboards type their printed characters; layout changes flow through `browser_runtime_config` and restart internal runtimes.
51 +- Browser keyboard layout settings (`keyboard_layout`/`keyboard_variant`, e.g. `de`/`mac`) apply the configured XKB layout to the private browser display with setxkbmap and pin it on the Xpra shadow server so non-US keyboards type their printed characters; layout changes flow through `browser_runtime_config` and restart internal runtimes. Fallback canvas input forwards AltGraph and macOS Option text without converting ordinary Alt shortcuts into text.
52 - Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads.
53 - `hooks.prepare_playwright_cache()` owns reconciliation of the pinned Patchright package and Chromium binary so repository self-updates and fresh images use the same setup path.
54 - Browser startup must install the shared virtual-desktop route hook itself; do not make Browser depend on the Desktop plugin being enabled.
plugins/_browser/webui/browser-store.js
+18 -2
@@ -86,6 +86,22 @@ function isLocalEditableTarget(target) {
86 return ["", "true", "plaintext-only"].includes(value);
87 }
88
89 +function isAltTextInput(event, platform = "") {
90 + const key = String(event?.key || "");
91 + if (key.length !== 1 || !event?.altKey || event.metaKey) return false;
92 + const targetPlatform = String(
93 + platform
94 + || globalThis.navigator?.userAgentData?.platform
95 + || globalThis.navigator?.platform
96 + || "",
97 + );
98 + return Boolean(
99 + event.ctrlKey
100 + || event.getModifierState?.("AltGraph")
101 + || /mac/i.test(targetPlatform),
102 + );
103 +}
104 +
105 function nextAnimationFrame() {
106 return new Promise((resolve) => {
107 const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
@@ -2909,8 +2925,8 @@ const model = {
2925 const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
2926 if (!contextId || !this.activeBrowserId) return;
2927 const printable = event.key && event.key.length === 1;
2912 - const altGrText = printable && event.altKey && !event.metaKey;
2913 - if ((event.ctrlKey || event.metaKey || event.altKey) && !altGrText) return;
2928 + const altText = isAltTextInput(event);
2929 + if ((event.ctrlKey || event.metaKey || event.altKey) && !altText) return;
2930 if (isLocalEditableTarget(event?.target)) return;
2931 event.preventDefault();
2932 await websocket.emit("browser_viewer_input", {
tests/test_browser_agent_regressions.py
+22
@@ -1973,6 +1973,28 @@ def test_browser_visual_mode_bridges_clipboard_shortcuts():
1973 assert 'runtime.call(\n "clipboard"' in ws_browser
1974
1975
1976 +def test_browser_visual_mode_only_forwards_text_producing_alt_keys():
1977 + browser_store = (
1978 + PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-store.js"
1979 + ).read_text(encoding="utf-8")
1980 + start = browser_store.index("function isAltTextInput")
1981 + end = browser_store.index("\n}\n", start) + 3
1982 + helper = browser_store[start:end]
1983 + script = helper + """
1984 +const check = (condition, message) => {
1985 + if (!condition) throw new Error(message);
1986 +};
1987 +check(isAltTextInput({ key: "@", altKey: true, ctrlKey: true }, "Windows"), "AltGr text was blocked");
1988 +check(isAltTextInput({ key: "€", altKey: true, getModifierState: (name) => name === "AltGraph" }, "Linux"), "AltGraph text was blocked");
1989 +check(isAltTextInput({ key: "@", altKey: true }, "MacIntel"), "Option text was blocked");
1990 +check(!isAltTextInput({ key: "f", altKey: true }, "Windows"), "Windows Alt shortcut became text");
1991 +check(!isAltTextInput({ key: "d", altKey: true }, "Linux"), "Linux Alt shortcut became text");
1992 +check(!isAltTextInput({ key: "@", altKey: true, metaKey: true }, "MacIntel"), "Command shortcut became text");
1993 +"""
1994 +
1995 + subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)
1996 +
1997 +
1998 def test_browser_runtime_and_content_helper_expose_annotation_target():
1999 runtime = (
2000 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"