authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-08-21 06:08:50+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 22:08:50-07:00
log18f6d034897d9cc5429dcb88c6a9795d183f1687
tree6dd9b275708c619ad2700d91aa59ef677865c18f
parent6d5c6c4a671c7b6fc9723c63c1003916fb3b8ab0
signature Signed by PGP key B5690EEEBB952194

fix(web): encode shifted characters correctly in the terminal (#7485)


3 files changed, 28 insertions(+), 2 deletions(-)

apps/web/src/terminal/ghostty/core.ts+6-1
......@@ -1,5 +1,6 @@
11import {
22 type GhosttyKeyboardLayoutMap,
3 ghosttyConsumedMods,
34 ghosttyKeyForCode,
45 ghosttyUnshiftedCodepoint,
56 loadGhosttyKeyboardLayoutMap,
......@@ -464,7 +465,11 @@ export class GhosttyTerminalCore {
464465 (event.getModifierState("CapsLock") ? 1 << 4 : 0) |
465466 (event.getModifierState("NumLock") ? 1 << 5 : 0);
466467 this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods);
467 this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, 0);
468 this.runtime.call(
469 "ghostty_key_event_set_consumed_mods",
470 this.keyEvent,
471 ghosttyConsumedMods(event),
472 );
468473 this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0);
469474 this.runtime.call(
470475 "ghostty_key_event_set_unshifted_codepoint",
apps/web/src/terminal/ghostty/keyCodes.test.ts+13-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes";
3import { ghosttyConsumedMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes";
44
55describe("ghosttyKeyForCode", () => {
66 it("keeps the tail of the pinned Ghostty key enum in order", () => {
......@@ -11,6 +11,18 @@ describe("ghosttyKeyForCode", () => {
1111 });
1212});
1313
14describe("ghosttyConsumedMods", () => {
15 const shifted = { altKey: false, ctrlKey: false, key: "@", metaKey: false, shiftKey: true };
16
17 it("only consumes a lone Shift producing a character", () => {
18 expect(ghosttyConsumedMods(shifted)).toBe(1);
19 expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true })).toBe(0);
20 expect(ghosttyConsumedMods({ ...shifted, key: "Tab" })).toBe(0);
21 // Deliberate: Shift+Space collapses to Space so it still types one.
22 expect(ghosttyConsumedMods({ ...shifted, key: " " })).toBe(1);
23 });
24});
25
1426describe("ghosttyUnshiftedCodepoint", () => {
1527 it("provides the logical base character for Kitty keyboard encoding", () => {
1628 expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "c", shiftKey: false })).toBe(
apps/web/src/terminal/ghostty/keyCodes.ts+9
......@@ -233,6 +233,15 @@ export function loadGhosttyKeyboardLayoutMap(): Promise<GhosttyKeyboardLayoutMap
233233 return promise;
234234}
235235
236// Browsers do not expose consumed modifiers; treat Shift as consumed for
237// unchorded character input.
238export function ghosttyConsumedMods(
239 event: Pick<KeyboardEvent, "altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey">,
240): number {
241 if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0;
242 return [...event.key].length === 1 ? 1 : 0;
243}
244
236245export function ghosttyUnshiftedCodepoint(
237246 event: Pick<KeyboardEvent, "code" | "key" | "shiftKey">,
238247 layoutMap?: GhosttyKeyboardLayoutMap,