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"