From b2e379ec64a5cde2f9148b84c4ad9c190854f5a7 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Wed, 23 Sep 2026 06:43:15 +0000 Subject: [PATCH] fix(web): type macOS Option characters in the terminal German Option+L produces @, but the terminal forwarded Option as Alt into the non-macOS libghostty-vt build. DEC 1036 then sent ESC @, which readline treats as set-mark, so the character never appeared. The same path dropped the rest of the Option layer. Consume a lone Option on macOS, and Shift when it helped produce the character, so the encoder writes the composed character. Ctrl, Meta, and Option+arrow stay chords. Fixes #13046 --- .../web/src/terminal/ghostty/keyCodes.test.ts | 27 +++++++++++++---- apps/web/src/terminal/ghostty/keyCodes.ts | 29 ++++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/apps/web/src/terminal/ghostty/keyCodes.test.ts b/apps/web/src/terminal/ghostty/keyCodes.test.ts index ef4b623fdfc8..053881efca72 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.test.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.test.ts @@ -14,12 +14,29 @@ describe("ghosttyKeyForCode", () => { describe("ghosttyConsumedMods", () => { const shifted = { altKey: false, ctrlKey: false, key: "@", metaKey: false, shiftKey: true }; - it("only consumes a lone Shift producing a character", () => { - expect(ghosttyConsumedMods(shifted)).toBe(1); - expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true })).toBe(0); - expect(ghosttyConsumedMods({ ...shifted, key: "Tab" })).toBe(0); + it("consumes a lone Shift producing a character", () => { + expect(ghosttyConsumedMods(shifted, "Linux")).toBe(1); + expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...shifted, key: "Tab" }, "MacIntel")).toBe(0); // Deliberate: Shift+Space collapses to Space so it still types one. - expect(ghosttyConsumedMods({ ...shifted, key: " " })).toBe(1); + expect(ghosttyConsumedMods({ ...shifted, key: " " }, "Linux")).toBe(1); + }); + + it("consumes a lone macOS Option that produced a character", () => { + const option = { altKey: true, ctrlKey: false, key: "@", metaKey: false, shiftKey: false }; + expect(ghosttyConsumedMods(option, "MacIntel")).toBe(1 << 2); + expect(ghosttyConsumedMods({ ...option, key: "€" }, "MacIntel")).toBe(1 << 2); + expect(ghosttyConsumedMods({ ...option, key: "∫" }, "MacIntel")).toBe(1 << 2); + expect(ghosttyConsumedMods({ ...option, key: "\\", shiftKey: true }, "MacIntel")).toBe( + (1 << 2) | 1, + ); + expect(ghosttyConsumedMods({ ...option, ctrlKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, metaKey: true }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, key: "ArrowLeft" }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods({ ...option, key: "Dead" }, "MacIntel")).toBe(0); + expect(ghosttyConsumedMods(option, "Linux")).toBe(0); + expect(ghosttyConsumedMods(option, "Win32")).toBe(0); + expect(ghosttyConsumedMods({ ...option, shiftKey: true }, "Linux")).toBe(0); }); }); diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts index a643827d6f2d..8c66188f7465 100644 --- a/apps/web/src/terminal/ghostty/keyCodes.ts +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -233,13 +233,34 @@ export function loadGhosttyKeyboardLayoutMap(): Promise, + platform = typeof navigator === "undefined" ? "" : (navigator.platform ?? ""), ): number { - if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0; - return [...event.key].length === 1 ? 1 : 0; + if ([...event.key].length !== 1 || event.ctrlKey || event.metaKey) return 0; + const shift = event.shiftKey ? GHOSTTY_MOD_SHIFT : 0; + // Same host check as isMacPlatform in lib/utils.ts. Kept local so this module + // stays free of the app utility graph. + if (event.altKey && /mac|iphone|ipad|ipod/i.test(platform)) return shift | GHOSTTY_MOD_ALT; + if (!event.shiftKey || event.altKey) return 0; + return GHOSTTY_MOD_SHIFT; } export function ghosttyUnshiftedCodepoint(