diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts
new file mode 100644
index 00000000000..b4c26252770
--- /dev/null
+++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { nativeKeybindingCaptureInput } from "./NativeKeybindingCapture.ts";
+
+describe("nativeKeybindingCaptureInput", () => {
+ it("forwards Command-Escape with its modifiers", () => {
+ expect(
+ nativeKeybindingCaptureInput({
+ type: "keyDown",
+ key: "Escape",
+ meta: true,
+ control: false,
+ alt: false,
+ shift: true,
+ }),
+ ).toEqual({
+ key: "Escape",
+ metaKey: true,
+ ctrlKey: false,
+ altKey: false,
+ shiftKey: true,
+ });
+ });
+
+ it.each([
+ ["bare Escape", { type: "keyDown", key: "Escape", meta: false }],
+ ["Command keyup", { type: "keyUp", key: "Escape", meta: true }],
+ ["another Command shortcut", { type: "keyDown", key: "k", meta: true }],
+ ])("ignores %s", (_name, input) => {
+ expect(
+ nativeKeybindingCaptureInput({
+ control: false,
+ alt: false,
+ shift: false,
+ ...input,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts
new file mode 100644
index 00000000000..b920c4a15e0
--- /dev/null
+++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts
@@ -0,0 +1,62 @@
+import type { Input } from "electron";
+
+export const NATIVE_KEYBINDING_CAPTURE_CHANNEL = "desktop:native-keybinding-capture";
+
+export interface NativeKeybindingCaptureInput {
+ readonly key: "Escape";
+ readonly metaKey: true;
+ readonly ctrlKey: boolean;
+ readonly altKey: boolean;
+ readonly shiftKey: boolean;
+}
+
+export function nativeKeybindingCaptureInput(
+ input: Pick,
+): NativeKeybindingCaptureInput | null {
+ const key = input.key.toLowerCase();
+ if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !input.meta) {
+ return null;
+ }
+
+ return {
+ key: "Escape",
+ metaKey: true,
+ ctrlKey: input.control,
+ altKey: input.alt,
+ shiftKey: input.shift,
+ };
+}
+
+export function dispatchNativeKeybindingCaptureInput(input: unknown): void {
+ if (
+ typeof input !== "object" ||
+ input === null ||
+ !("key" in input) ||
+ input.key !== "Escape" ||
+ !("metaKey" in input) ||
+ input.metaKey !== true ||
+ !("ctrlKey" in input) ||
+ typeof input.ctrlKey !== "boolean" ||
+ !("altKey" in input) ||
+ typeof input.altKey !== "boolean" ||
+ !("shiftKey" in input) ||
+ typeof input.shiftKey !== "boolean"
+ ) {
+ return;
+ }
+
+ const target = document.activeElement ?? document;
+
+ target.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: input.key,
+ code: "Escape",
+ metaKey: input.metaKey,
+ ctrlKey: input.ctrlKey,
+ altKey: input.altKey,
+ shiftKey: input.shiftKey,
+ bubbles: true,
+ cancelable: true,
+ }),
+ );
+}
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index 228114fd1d1..87fb0ed57a3 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -8,9 +8,17 @@ import { exposeClerkBridge } from "@clerk/electron/preload";
import { contextBridge, ipcRenderer } from "electron";
import * as IpcChannels from "./ipc/channels.ts";
+import {
+ dispatchNativeKeybindingCaptureInput,
+ NATIVE_KEYBINDING_CAPTURE_CHANNEL,
+} from "./keybindings/NativeKeybindingCapture.ts";
exposeClerkBridge({ passkeys: true });
+ipcRenderer.on(NATIVE_KEYBINDING_CAPTURE_CHANNEL, (_event, input: unknown) => {
+ dispatchNativeKeybindingCaptureInput(input);
+});
+
function unwrapEnsureSshEnvironmentResult(result: unknown) {
if (
typeof result === "object" &&
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index f1215ee7b60..a57be278e7f 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -16,6 +16,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
+import { NATIVE_KEYBINDING_CAPTURE_CHANNEL } from "../keybindings/NativeKeybindingCapture.ts";
import * as BrowserSession from "./BrowserSession.ts";
import * as PreviewManager from "./Manager.ts";
@@ -260,6 +261,27 @@ describe("PreviewManager", () => {
expect(loadURL).toHaveBeenCalledOnce();
expect(loadURL).toHaveBeenCalledWith("http://localhost:3200/");
+
+ const beforeInputEvent = { preventDefault: vi.fn() };
+ listeners.get("before-input-event")?.(
+ beforeInputEvent as never,
+ {
+ type: "keyDown",
+ key: "Escape",
+ meta: true,
+ control: false,
+ alt: false,
+ shift: false,
+ } as never,
+ );
+ expect(webviewSend).toHaveBeenCalledWith(NATIVE_KEYBINDING_CAPTURE_CHANNEL, {
+ key: "Escape",
+ metaKey: true,
+ ctrlKey: false,
+ altKey: false,
+ shiftKey: false,
+ });
+ expect(beforeInputEvent.preventDefault).toHaveBeenCalledOnce();
}),
),
);
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 6c942d4ccb9..15a436c17a8 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -53,6 +53,10 @@ import * as Scope from "effect/Scope";
import * as SynchronizedRef from "effect/SynchronizedRef";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
+import {
+ nativeKeybindingCaptureInput,
+ NATIVE_KEYBINDING_CAPTURE_CHANNEL,
+} from "../keybindings/NativeKeybindingCapture.ts";
import * as BrowserSession from "./BrowserSession.ts";
import {
ANNOTATION_CAPTURED_CHANNEL,
@@ -1229,6 +1233,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
});
});
const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
+ const captureInput = nativeKeybindingCaptureInput(input);
+ if (captureInput) {
+ event.preventDefault();
+ wc.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput);
+ }
runFork(forwardShortcut(event, input));
};
yield* Scope.addFinalizer(
diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts
index 2654b898102..31b8f879259 100644
--- a/apps/desktop/src/preview/PickPreload.ts
+++ b/apps/desktop/src/preview/PickPreload.ts
@@ -1,5 +1,10 @@
// @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime.
import { ipcRenderer } from "electron";
+
+import {
+ dispatchNativeKeybindingCaptureInput,
+ NATIVE_KEYBINDING_CAPTURE_CHANNEL,
+} from "../keybindings/NativeKeybindingCapture.ts";
import { getElementContext } from "react-grab/primitives";
import type {
DesktopPreviewAnnotationTheme,
@@ -22,6 +27,10 @@ import {
HUMAN_INPUT_CHANNEL,
START_PICK_CHANNEL,
} from "./GuestProtocol.ts";
+
+ipcRenderer.on(NATIVE_KEYBINDING_CAPTURE_CHANNEL, (_event, input: unknown) => {
+ dispatchNativeKeybindingCaptureInput(input);
+});
const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
const Z_INDEX_OVERLAY = 2147483646;
const PRIMARY = "var(--t3-primary)";
diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
index 587da8d4431..91a9efefc0a 100644
--- a/apps/desktop/src/window/DesktopWindow.test.ts
+++ b/apps/desktop/src/window/DesktopWindow.test.ts
@@ -14,8 +14,25 @@ import * as TestClock from "effect/testing/TestClock";
import * as Electron from "electron";
import { vi } from "vite-plus/test";
+const {
+ globalShortcutIsRegistered,
+ globalShortcutRegister,
+ globalShortcutUnregister,
+ getFocusedWebContents,
+} = vi.hoisted(() => ({
+ globalShortcutIsRegistered: vi.fn<(accelerator: string) => boolean>(() => false),
+ globalShortcutRegister: vi.fn<(accelerator: string, callback: () => void) => boolean>(() => true),
+ globalShortcutUnregister: vi.fn<(accelerator: string) => void>(),
+ getFocusedWebContents: vi.fn<() => Electron.WebContents | null>(() => null),
+}));
+
vi.mock("electron", async (importOriginal) => ({
...(await importOriginal()),
+ globalShortcut: {
+ isRegistered: globalShortcutIsRegistered,
+ register: globalShortcutRegister,
+ unregister: globalShortcutUnregister,
+ },
session: {
fromPartition: vi.fn(() => ({
getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"),
@@ -30,6 +47,9 @@ vi.mock("electron", async (importOriginal) => ({
},
]),
},
+ webContents: {
+ getFocusedWebContents,
+ },
}));
import * as DesktopAssets from "../app/DesktopAssets.ts";
@@ -42,6 +62,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
+import { NATIVE_KEYBINDING_CAPTURE_CHANNEL } from "../keybindings/NativeKeybindingCapture.ts";
import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
import * as DesktopWindow from "./DesktopWindow.ts";
import * as PreviewManager from "../preview/Manager.ts";
@@ -432,6 +453,35 @@ describe("DesktopWindow", () => {
assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]);
assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]);
assert.equal(fakeWindow.openDevTools.mock.calls.length, 1);
+
+ const focusedWebContents = {
+ isDestroyed: vi.fn(() => false),
+ send: vi.fn(),
+ };
+ getFocusedWebContents.mockReturnValue(
+ focusedWebContents as unknown as Electron.WebContents,
+ );
+ fakeWindow.windowListeners.get("focus")?.();
+ const registration = globalShortcutRegister.mock.calls.at(-1);
+ if (!registration) {
+ assert.fail("expected Command-Escape to be registered");
+ }
+ assert.equal(registration[0], "Command+Escape");
+ registration[1]();
+ assert.deepEqual(focusedWebContents.send.mock.calls, [
+ [
+ NATIVE_KEYBINDING_CAPTURE_CHANNEL,
+ {
+ key: "Escape",
+ metaKey: true,
+ ctrlKey: false,
+ altKey: false,
+ shiftKey: false,
+ },
+ ],
+ ]);
+ fakeWindow.windowListeners.get("blur")?.();
+ assert.deepEqual(globalShortcutUnregister.mock.calls.at(-1), ["Command+Escape"]);
}).pipe(Effect.provide(layer));
}),
);
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index db4b698434d..fe6053c85bb 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -18,6 +18,11 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts";
import * as PreviewManager from "../preview/Manager.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
+import {
+ type NativeKeybindingCaptureInput,
+ nativeKeybindingCaptureInput,
+ NATIVE_KEYBINDING_CAPTURE_CHANNEL,
+} from "../keybindings/NativeKeybindingCapture.ts";
const TITLEBAR_HEIGHT = 40;
const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
@@ -25,6 +30,14 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500;
const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;
+const MACOS_MOD_ESCAPE_ACCELERATOR = "Command+Escape";
+const MACOS_MOD_ESCAPE_INPUT: NativeKeybindingCaptureInput = {
+ key: "Escape",
+ metaKey: true,
+ ctrlKey: false,
+ altKey: false,
+ shiftKey: false,
+};
const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([
-2, // ERR_FAILED
-7, // ERR_TIMED_OUT
@@ -341,6 +354,36 @@ export const make = Effect.gen(function* () {
if (environment.platform === "darwin") {
window.setAutoHideCursor(false);
}
+ let unregisterNativeModEscape = () => {};
+ if (environment.platform === "darwin") {
+ const registerNativeModEscape = () => {
+ if (Electron.globalShortcut.isRegistered(MACOS_MOD_ESCAPE_ACCELERATOR)) {
+ return;
+ }
+ const registered = Electron.globalShortcut.register(MACOS_MOD_ESCAPE_ACCELERATOR, () => {
+ const focusedWebContents = Electron.webContents.getFocusedWebContents();
+ if (focusedWebContents && !focusedWebContents.isDestroyed()) {
+ focusedWebContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, MACOS_MOD_ESCAPE_INPUT);
+ }
+ });
+ if (!registered) {
+ void runPromise(logWindowWarning("failed to register Command-Escape shortcut"));
+ }
+ };
+ unregisterNativeModEscape = () => {
+ Electron.globalShortcut.unregister(MACOS_MOD_ESCAPE_ACCELERATOR);
+ };
+ window.on("focus", registerNativeModEscape);
+ window.on("blur", unregisterNativeModEscape);
+ } else {
+ window.webContents.on("before-input-event", (event, input) => {
+ const captureInput = nativeKeybindingCaptureInput(input);
+ if (captureInput) {
+ event.preventDefault();
+ window.webContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput);
+ }
+ });
+ }
let boundsPersistFiber: Fiber.Fiber | undefined;
let pendingBoundsPersistFiber: Fiber.Fiber | undefined;
let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds;
@@ -644,6 +687,7 @@ export const make = Effect.gen(function* () {
}
window.on("closed", () => {
+ unregisterNativeModEscape();
clearDevelopmentLoadRetry();
clearBoundsPersist();
void runPromise(electronWindow.clearMain(Option.some(window)));
diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
index 75235a05307..62b6d74c191 100644
--- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
+++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
@@ -64,6 +64,38 @@ describe("KeybindingsSettings.logic", () => {
).toBe("mod+shift+k");
});
+ it.each([
+ ["MacIntel", { metaKey: true, ctrlKey: false }],
+ ["Win32", { metaKey: false, ctrlKey: true }],
+ ])("captures modified Escape as mod+esc on %s", (platform, modifiers) => {
+ expect(
+ keybindingFromKeyboardEvent(
+ {
+ key: "Escape",
+ ...modifiers,
+ altKey: false,
+ shiftKey: false,
+ },
+ platform,
+ ),
+ ).toBe("mod+esc");
+ });
+
+ it("leaves unmodified Escape available to cancel keybinding capture", () => {
+ expect(
+ keybindingFromKeyboardEvent(
+ {
+ key: "Escape",
+ metaKey: false,
+ ctrlKey: false,
+ altKey: false,
+ shiftKey: false,
+ },
+ "MacIntel",
+ ),
+ ).toBeNull();
+ });
+
it("serializes shortcuts and when expressions for upserts", () => {
expect(
shortcutToKeybindingInput({
diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx
index 67c33c9b942..7c2880fbafa 100644
--- a/apps/web/src/components/settings/KeybindingsSettings.tsx
+++ b/apps/web/src/components/settings/KeybindingsSettings.tsx
@@ -803,11 +803,11 @@ function KeybindingTableRow({
const captureKeybinding = (event: KeyboardEvent) => {
if (event.key === "Tab") return;
event.preventDefault();
- if (event.key === "Escape") {
+ const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
+ if (!next && event.key === "Escape") {
setDraft({ keyDraft: row.key, isRecording: false });
return;
}
- const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
if (!next) return;
setDraft({ keyDraft: next, isRecording: false });
};
@@ -846,8 +846,8 @@ function KeybindingTableRow({
) : (
) => {
if (event.key === "Tab") return;
event.preventDefault();
- if (event.key === "Escape") {
+ const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
+ if (!next && event.key === "Escape") {
setDraft({ keyDraft: "", isRecording: false });
return;
}
- const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform);
if (!next) return;
setDraft({ keyDraft: next, isRecording: false });
};