From e204807b1c217abaed6fc2ceb579b7978fb19a4c Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Wed, 13 May 2026 14:10:57 +0100 Subject: [PATCH 1/2] Fix terminal toggle shortcut handling on Windows --- .../ThreadTerminalDrawer.browser.tsx | 110 +++++++++++++++++- apps/web/src/keybindings.test.ts | 17 +++ apps/web/src/keybindings.ts | 15 ++- 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 2df2e04f5c4..66dc09b6a13 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; +import type { ComponentProps } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -10,6 +11,8 @@ const { terminalDisposeSpy, fitAddonFitSpy, fitAddonLoadSpy, + attachedCustomKeyEventHandlerSpy, + latestCustomKeyEventHandler, environmentApiById, readEnvironmentApiMock, readLocalApiMock, @@ -18,6 +21,10 @@ const { terminalDisposeSpy: vi.fn(), fitAddonFitSpy: vi.fn(), fitAddonLoadSpy: vi.fn(), + attachedCustomKeyEventHandlerSpy: vi.fn(), + latestCustomKeyEventHandler: { + current: undefined as ((event: KeyboardEvent) => boolean) | undefined, + }, environmentApiById: new Map } }>(), readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), readLocalApiMock: vi.fn< @@ -86,8 +93,9 @@ vi.mock("@xterm/xterm", () => ({ return null; } - attachCustomKeyEventHandler() { - return true; + attachCustomKeyEventHandler(handler?: (event: KeyboardEvent) => boolean) { + latestCustomKeyEventHandler.current = handler; + attachedCustomKeyEventHandlerSpy(handler); } registerLinkProvider() { @@ -148,6 +156,7 @@ async function mountTerminalViewport(props: { threadRef: ReturnType; drawerBackgroundColor?: string; drawerTextColor?: string; + keybindings?: ComponentProps["keybindings"]; }) { const drawer = document.createElement("div"); drawer.className = "thread-terminal-drawer"; @@ -177,7 +186,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} - keybindings={[]} + keybindings={props.keybindings ?? []} />, { container: host }, ); @@ -197,7 +206,7 @@ async function mountTerminalViewport(props: { autoFocus={false} resizeEpoch={0} drawerHeight={320} - keybindings={[]} + keybindings={props.keybindings ?? []} />, ); }, @@ -217,6 +226,8 @@ describe("TerminalViewport", () => { terminalDisposeSpy.mockClear(); fitAddonFitSpy.mockClear(); fitAddonLoadSpy.mockClear(); + attachedCustomKeyEventHandlerSpy.mockClear(); + latestCustomKeyEventHandler.current = undefined; }); it("does not create a terminal when APIs are unavailable", async () => { @@ -317,4 +328,95 @@ describe("TerminalViewport", () => { await mounted.cleanup(); } }); + + it.each([ + { + name: "macOS", + platform: "MacIntel", + keyboardEvent: { + type: "keydown", + key: "j", + code: "KeyJ", + ctrlKey: false, + metaKey: true, + shiftKey: false, + altKey: false, + }, + }, + { + name: "Linux", + platform: "Linux x86_64", + keyboardEvent: { + type: "keydown", + key: "j", + code: "KeyJ", + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + }, + }, + { + name: "Windows control-character", + platform: "Win32", + keyboardEvent: { + type: "keydown", + key: "\n", + code: "KeyJ", + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + }, + }, + ])("yields $name terminal.toggle events back to the app", async ({ platform, keyboardEvent }) => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform"); + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: platform, + }); + + try { + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + keybindings: [ + { + command: "terminal.toggle", + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ], + }); + + try { + await vi.waitFor(() => { + expect(attachedCustomKeyEventHandlerSpy).toHaveBeenCalledTimes(1); + }); + + const handledByTerminal = latestCustomKeyEventHandler.current?.({ + ...keyboardEvent, + preventDefault() {}, + stopPropagation() {}, + } as unknown as KeyboardEvent); + + expect(handledByTerminal).toBe(false); + } finally { + await mounted.cleanup(); + } + } finally { + if (platformDescriptor) { + Object.defineProperty(window.navigator, "platform", platformDescriptor); + } else { + delete (window.navigator as { platform?: string }).platform; + } + } + }); }); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 85c14fa0ab7..4e17d973329 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -163,6 +163,23 @@ describe("isTerminalToggleShortcut", () => { }), ); }); + + it("matches Ctrl+J on non-macOS from a control-character key event", () => { + assert.isTrue( + isTerminalToggleShortcut( + event({ + key: "\n", + code: "KeyJ", + ctrlKey: true, + }), + DEFAULT_BINDINGS, + { + platform: "Win32", + context: { terminalFocus: true }, + }, + ), + ); + }); }); describe("split/new/close terminal shortcuts", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index dbf2450f794..210591e7853 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -62,6 +62,16 @@ const EVENT_CODE_KEY_ALIASES: Readonly> = { Digit9: ["9"], }; +function eventCodeAliases(code: string | undefined): readonly string[] { + if (!code) return []; + + if (code.startsWith("Key") && code.length === 4) { + return [code.slice(3).toLowerCase()]; + } + + return EVENT_CODE_KEY_ALIASES[code] ?? []; +} + function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); if (normalized === "esc") return "escape"; @@ -70,10 +80,7 @@ function normalizeEventKey(key: string): string { function resolveEventKeys(event: ShortcutEventLike): Set { const keys = new Set([normalizeEventKey(event.key)]); - const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined; - if (!aliases) return keys; - - for (const alias of aliases) { + for (const alias of eventCodeAliases(event.code)) { keys.add(alias); } return keys; From 9bbfd409633ca76bdb870b64c84c8510a0c7597f Mon Sep 17 00:00:00 2001 From: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 14 May 2026 09:47:22 +0100 Subject: [PATCH 2/2] Align terminal browser test ref typing with review --- apps/web/src/components/ThreadTerminalDrawer.browser.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 66dc09b6a13..bef06f7a400 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -2,7 +2,7 @@ import "../index.css"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; -import type { ComponentProps } from "react"; +import type * as React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -23,8 +23,8 @@ const { fitAddonLoadSpy: vi.fn(), attachedCustomKeyEventHandlerSpy: vi.fn(), latestCustomKeyEventHandler: { - current: undefined as ((event: KeyboardEvent) => boolean) | undefined, - }, + current: undefined, + } as React.MutableRefObject<((event: KeyboardEvent) => boolean) | undefined>, environmentApiById: new Map } }>(), readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), readLocalApiMock: vi.fn< @@ -156,7 +156,7 @@ async function mountTerminalViewport(props: { threadRef: ReturnType; drawerBackgroundColor?: string; drawerTextColor?: string; - keybindings?: ComponentProps["keybindings"]; + keybindings?: React.ComponentProps["keybindings"]; }) { const drawer = document.createElement("div"); drawer.className = "thread-terminal-drawer";