From 06cec678ed22fbe19ae88462040212cd9a8e22fe Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 7 Oct 2025 22:33:51 +0200 Subject: [PATCH] fix: add mac ctrl behavior overrides for keybinds Introduce optional macCtrlBehavior on keybind definitions to control when Control and Command modifiers are accepted on macOS shortcuts. Require Control for the interrupt shortcut and align display formatting. --- src/utils/ui/keybinds.ts | 80 ++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index 550cfdb2d..317b83e59 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -15,6 +15,14 @@ export interface Keybind { shift?: boolean; alt?: boolean; meta?: boolean; + /** + * On macOS, Ctrl-based shortcuts traditionally use Cmd instead. + * Use this field to control that behavior: + * - "either" (default): accept Ctrl or Cmd + * - "command": require Cmd specifically + * - "control": require the Control key specifically + */ + macCtrlBehavior?: "either" | "command" | "control"; } /** @@ -35,7 +43,7 @@ export function isMac(): boolean { /** * Check if a keyboard event matches a keybind definition. - * On macOS, ctrl in the definition matches either ctrl OR meta (Cmd) in the event. + * On macOS, ctrl in the definition defaults to matching Ctrl or Cmd unless overridden. */ export function matchesKeybind( event: React.KeyboardEvent | KeyboardEvent, @@ -46,12 +54,48 @@ export function matchesKeybind( return false; } - // On Mac, treat ctrl and meta as equivalent - const ctrlOrMeta = isMac() ? event.ctrlKey || event.metaKey : event.ctrlKey; + const onMac = isMac(); + const macCtrlBehavior = keybind.macCtrlBehavior ?? "either"; + const ctrlPressed = event.ctrlKey; + const metaPressed = event.metaKey; + + let ctrlRequired = false; + let ctrlAllowed = false; + let metaRequired = keybind.meta ?? false; + let metaAllowed = metaRequired; + + if (keybind.ctrl) { + if (onMac) { + switch (macCtrlBehavior) { + case "control": { + ctrlRequired = true; + ctrlAllowed = true; + // Only allow Cmd if explicitly requested via meta flag + break; + } + case "command": { + metaRequired = true; + metaAllowed = true; + ctrlAllowed = true; + break; + } + case "either": { + ctrlAllowed = true; + metaAllowed = true; + if (!ctrlPressed && !metaPressed) return false; + break; + } + } + } else { + ctrlRequired = true; + ctrlAllowed = true; + } + } else { + ctrlAllowed = false; + } - // Check modifiers - if (keybind.ctrl && !ctrlOrMeta) return false; - if (!keybind.ctrl && ctrlOrMeta) return false; + if (ctrlRequired && !ctrlPressed) return false; + if (!ctrlAllowed && ctrlPressed) return false; if (keybind.shift && !event.shiftKey) return false; if (!keybind.shift && event.shiftKey) return false; @@ -59,10 +103,17 @@ export function matchesKeybind( if (keybind.alt && !event.altKey) return false; if (!keybind.alt && event.altKey) return false; - // meta is explicit (only check when not handled by ctrl equivalence) - if (!isMac()) { - if (keybind.meta && !event.metaKey) return false; - if (!keybind.meta && event.metaKey) return false; + if (metaRequired && !metaPressed) return false; + + if (!metaAllowed) { + // If Cmd is allowed implicitly via ctrl behavior, mark it now + if (onMac && keybind.ctrl && macCtrlBehavior !== "control") { + metaAllowed = true; + } + } + + if (!metaAllowed && metaPressed) { + return false; } return true; @@ -92,7 +143,12 @@ export function formatKeybind(keybind: Keybind): string { // Mac-style formatting with symbols (using Unicode escapes for safety) // For ctrl on Mac, we actually mean Cmd in most cases since matcher treats them as equivalent if (keybind.ctrl && !keybind.meta) { - parts.push("\u2318"); // ⌘ Command + const macCtrlBehavior = keybind.macCtrlBehavior ?? "either"; + if (macCtrlBehavior === "control") { + parts.push("\u2303"); // ⌃ Control + } else { + parts.push("\u2318"); // ⌘ Command + } } else if (keybind.ctrl) { parts.push("\u2303"); // ⌃ Control } @@ -134,7 +190,7 @@ export const KEYBINDS = { CANCEL: { key: "Escape" }, /** Interrupt active stream (destructive - stops AI generation) */ - INTERRUPT_STREAM: { key: "c", ctrl: true }, + INTERRUPT_STREAM: { key: "c", ctrl: true, macCtrlBehavior: "control" }, /** Focus chat input */ FOCUS_INPUT_I: { key: "i" },