diff --git a/packages/domains/task-terminals/src/client/TerminalContextMenu.tsx b/packages/domains/task-terminals/src/client/TerminalContextMenu.tsx index 0e4b47ea..d981d69e 100644 --- a/packages/domains/task-terminals/src/client/TerminalContextMenu.tsx +++ b/packages/domains/task-terminals/src/client/TerminalContextMenu.tsx @@ -8,7 +8,8 @@ import { ContextMenuTrigger, useShortcutDisplay, useAppearance, - appearanceDefaults + appearanceDefaults, + detectPlatform } from '@slayzone/ui' import { Copy, @@ -60,6 +61,12 @@ export function TerminalContextMenu({ const [hasSelection, setHasSelection] = useState(false) const { terminalFontSize } = useAppearance() + // Copy/Paste aren't registry shortcuts: macOS uses Cmd+C/V via xterm natively, + // while Windows/Linux use Ctrl+Shift+C/V (Terminal's DOM keydown listener), since + // plain Ctrl+C is reserved for SIGINT in a terminal. + const isMac = detectPlatform() === 'mac' + const copyShortcut = isMac ? '⌘C' : 'Ctrl+Shift+C' + const pasteShortcut = isMac ? '⌘V' : 'Ctrl+Shift+V' const searchShortcut = useShortcutDisplay('terminal-search') const clearShortcut = useShortcutDisplay('terminal-clear') const splitShortcut = useShortcutDisplay('terminal-split') @@ -123,12 +130,12 @@ export function TerminalContextMenu({ Copy - ⌘C + {copyShortcut} Paste - ⌘V + {pasteShortcut} diff --git a/packages/domains/task/src/client/CreateTaskDialog.tsx b/packages/domains/task/src/client/CreateTaskDialog.tsx index 27f52fc6..4538675f 100644 --- a/packages/domains/task/src/client/CreateTaskDialog.tsx +++ b/packages/domains/task/src/client/CreateTaskDialog.tsx @@ -23,7 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@slayzone/ui' import { Calendar } from '@slayzone/ui' import { Checkbox } from '@slayzone/ui' import { ProjectSelect } from '@slayzone/projects' -import { buildStatusOptions, cn } from '@slayzone/ui' +import { buildStatusOptions, cn, formatKeysForDisplay, isPrimaryModifier } from '@slayzone/ui' interface CreateTaskDialogProps { open: boolean @@ -186,10 +186,10 @@ export function CreateTaskDialog({
{ - if (e.key === 'Enter' && e.metaKey && e.shiftKey) { + if (e.key === 'Enter' && isPrimaryModifier(e) && e.shiftKey) { e.preventDefault() form.handleSubmit(onSubmit)() - } else if (e.key === 'Enter' && e.metaKey) { + } else if (e.key === 'Enter' && isPrimaryModifier(e)) { e.preventDefault() if (onCreatedAndOpen) { form.handleSubmit((data) => createTask(data, { andOpen: true }))() @@ -450,7 +450,7 @@ export function CreateTaskDialog({ {onCreatedAndOpen && ( @@ -460,7 +460,7 @@ export function CreateTaskDialog({ > Create + open - ⌘↩ + {formatKeysForDisplay('mod+enter')} )} diff --git a/packages/domains/tasks/src/client/KanbanColumn.tsx b/packages/domains/tasks/src/client/KanbanColumn.tsx index ccd2bc91..2dccc561 100644 --- a/packages/domains/tasks/src/client/KanbanColumn.tsx +++ b/packages/domains/tasks/src/client/KanbanColumn.tsx @@ -20,7 +20,7 @@ import { DropdownMenuItem, DropdownMenuTrigger } from '@slayzone/ui' -import { cn, getColumnStatusStyle } from '@slayzone/ui' +import { cn, getColumnStatusStyle, isPrimaryModifier } from '@slayzone/ui' interface SortableKanbanCardProps { task: Task @@ -123,7 +123,7 @@ function SortableKanbanCard({ isFocused={isFocused} isSelected={isSelected} isMultiDragGhost={isMultiDragGhost} - onClick={(e) => onTaskClick?.(task, e)} + onClick={(e) => onTaskClick?.(task, { metaKey: isPrimaryModifier(e), shiftKey: e.shiftKey })} isBlocked={isBlocked} subTaskCount={subTaskCount} cardProperties={cardProperties} diff --git a/packages/domains/tasks/src/client/KanbanListView.tsx b/packages/domains/tasks/src/client/KanbanListView.tsx index d1a7d13d..48d7fca9 100644 --- a/packages/domains/tasks/src/client/KanbanListView.tsx +++ b/packages/domains/tasks/src/client/KanbanListView.tsx @@ -44,6 +44,7 @@ import { PriorityIcon } from '@slayzone/ui' import { IconButton } from '@slayzone/ui' +import { isPrimaryModifier } from '@slayzone/ui' import { ChevronDown, Plus, AlertCircle, AlarmClockOff, Check, GitMerge, Link2 } from 'lucide-react' import { usePty, useActiveTaskIds } from '@slayzone/terminal' import { useDialogStore } from '@slayzone/settings/client' @@ -203,7 +204,7 @@ function ListRowContent({ isDragging && 'opacity-50', isTerminalStatus(task.status, columns) && 'opacity-60' )} - onClick={(e) => onClick?.(task, e)} + onClick={(e) => onClick?.(task, { metaKey: isPrimaryModifier(e) })} > {/* Priority bar */} {(cp?.priority ?? true) && } diff --git a/packages/domains/terminal/src/client/Terminal.tsx b/packages/domains/terminal/src/client/Terminal.tsx index ec1bf9ac..57732c22 100644 --- a/packages/domains/terminal/src/client/Terminal.tsx +++ b/packages/domains/terminal/src/client/Terminal.tsx @@ -8,7 +8,13 @@ import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHand import { CheckCircle2, XCircle, Loader2 } from 'lucide-react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' -import { matchesShortcut, useShortcutStore, PulseGrid } from '@slayzone/ui' +import { + matchesShortcut, + isPrimaryModifier, + formatKeysForDisplay, + useShortcutStore, + PulseGrid +} from '@slayzone/ui' import { WebLinkProvider, FileLinkProvider } from './web-link-provider' import { SerializeAddon } from '@xterm/addon-serialize' import { SearchAddon } from '@xterm/addon-search' @@ -520,8 +526,10 @@ export const Terminal = forwardRef(function Termi if (tooltipEl) tooltipEl.style.display = 'none' } - const urlHint = '— ⌘+Click open · ⌘⇧+Click external' - const fileHint = '— ⌘+Click open' + const modKey = formatKeysForDisplay('mod') + const shiftKey = formatKeysForDisplay('shift') + const urlHint = `— ${modKey}+Click open · ${modKey}+${shiftKey}+Click external` + const fileHint = `— ${modKey}+Click open` // xterm measures the character cell from whatever font is loaded when // open() runs. If the terminal webfont has not loaded yet (cold start) @@ -555,11 +563,11 @@ export const Terminal = forwardRef(function Termi // a confirm() dialog + window.open(). linkHandler: { activate: (event: MouseEvent, uri: string) => { - if (event.metaKey && event.shiftKey) { + if (isPrimaryModifier(event) && event.shiftKey) { void window.api.shell.openExternal(uri) - } else if (event.metaKey && onOpenUrlRef.current) { + } else if (isPrimaryModifier(event) && onOpenUrlRef.current) { onOpenUrlRef.current(uri) - } else if (event.metaKey) { + } else if (isPrimaryModifier(event)) { void window.api.shell.openExternal(uri) } }, @@ -587,11 +595,11 @@ export const Terminal = forwardRef(function Termi const linkProvider = new WebLinkProvider( terminal, (event, uri) => { - if (event.metaKey && event.shiftKey) { + if (isPrimaryModifier(event) && event.shiftKey) { void window.api.shell.openExternal(uri) - } else if (event.metaKey && onOpenUrlRef.current) { + } else if (isPrimaryModifier(event) && onOpenUrlRef.current) { onOpenUrlRef.current(uri) - } else if (event.metaKey) { + } else if (isPrimaryModifier(event)) { void window.api.shell.openExternal(uri) } }, @@ -606,7 +614,7 @@ export const Terminal = forwardRef(function Termi new FileLinkProvider( terminal, (event, filePath, line, col) => { - if (!event.metaKey) return + if (!isPrimaryModifier(event)) return // Resolve relative paths against terminal cwd const resolved = filePath.startsWith('/') ? filePath : `${cwd}/${filePath}` const isInProject = resolved.startsWith(cwd + '/') || resolved === cwd diff --git a/packages/shared/shortcuts/src/accelerator.ts b/packages/shared/shortcuts/src/accelerator.ts index 434b69cb..832a04c3 100644 --- a/packages/shared/shortcuts/src/accelerator.ts +++ b/packages/shared/shortcuts/src/accelerator.ts @@ -5,6 +5,7 @@ const DISPLAY_MAP_MAC: Record = { shift: '⇧', alt: '⌥', ctrl: '⌃', + enter: '↩', period: '.', comma: ',', slash: '/', @@ -15,6 +16,7 @@ const DISPLAY_MAP_OTHER: Record = { shift: 'Shift', alt: 'Alt', ctrl: 'Ctrl', + enter: '↩', period: '.', comma: ',', slash: '/', diff --git a/packages/shared/shortcuts/src/index.test.ts b/packages/shared/shortcuts/src/index.test.ts index 0a97b336..d03c0b73 100644 --- a/packages/shared/shortcuts/src/index.test.ts +++ b/packages/shared/shortcuts/src/index.test.ts @@ -8,6 +8,7 @@ import { shortcutDefinitions, MENU_SHORTCUT_DEFAULTS, detectPlatform, + isPrimaryModifier, getBlockedWebPanelKeys, type ElectronInput } from './index' @@ -270,6 +271,24 @@ describe('detectPlatform', () => { }) }) +describe('isPrimaryModifier', () => { + const isMac = detectPlatform() === 'mac' + + it('treats Cmd as primary on macOS and Ctrl elsewhere', () => { + expect(isPrimaryModifier({ metaKey: true, ctrlKey: false })).toBe(isMac) + expect(isPrimaryModifier({ metaKey: false, ctrlKey: true })).toBe(!isMac) + }) + + it('returns false when no modifier is pressed', () => { + expect(isPrimaryModifier({ metaKey: false, ctrlKey: false })).toBe(false) + }) + + it('off macOS, does not fire when meta (Win/Super) is also held', () => { + // Mirrors the registry rejecting non-mac events with metaKey set. + expect(isPrimaryModifier({ metaKey: true, ctrlKey: true })).toBe(isMac) + }) +}) + describe('getBlockedWebPanelKeys', () => { it('includes OS reserved keys', () => { const blocked = getBlockedWebPanelKeys() diff --git a/packages/shared/shortcuts/src/index.ts b/packages/shared/shortcuts/src/index.ts index f15dfa28..754284de 100644 --- a/packages/shared/shortcuts/src/index.ts +++ b/packages/shared/shortcuts/src/index.ts @@ -1,4 +1,4 @@ -export { detectPlatform, type Platform } from './platform' +export { detectPlatform, isPrimaryModifier, type Platform } from './platform' export { type ShortcutScope, SCOPE_PRIORITY } from './scope' export { shortcutDefinitions, diff --git a/packages/shared/shortcuts/src/platform.ts b/packages/shared/shortcuts/src/platform.ts index 2ac5bebb..84e1da20 100644 --- a/packages/shared/shortcuts/src/platform.ts +++ b/packages/shared/shortcuts/src/platform.ts @@ -13,3 +13,15 @@ export function detectPlatform(): Platform { } return cached } + +/** + * Whether the platform's primary shortcut modifier is pressed for an event. + * Maps to Cmd (metaKey) on macOS and Ctrl (ctrlKey) everywhere else — the same + * `mod` semantics used by the shortcut registry, but for raw mouse/keyboard + * handlers that can't go through `matchesShortcut`. On Windows/Linux metaKey is + * the Super/Win key, so handlers must not gate on it — and, mirroring the + * registry's non-mac rejection of meta, must not fire while it is also held. + */ +export function isPrimaryModifier(e: { metaKey: boolean; ctrlKey: boolean }): boolean { + return detectPlatform() === 'mac' ? e.metaKey : e.ctrlKey && !e.metaKey +} diff --git a/packages/shared/ui/src/index.ts b/packages/shared/ui/src/index.ts index d765d1f8..c3366636 100644 --- a/packages/shared/ui/src/index.ts +++ b/packages/shared/ui/src/index.ts @@ -9,6 +9,8 @@ export { withShortcut, toElectronAccelerator, matchesShortcut, + isPrimaryModifier, + detectPlatform, SCOPE_PRIORITY, registry, scopeTracker, diff --git a/packages/shared/ui/src/shortcut-definitions.ts b/packages/shared/ui/src/shortcut-definitions.ts index db023444..6ff64769 100644 --- a/packages/shared/ui/src/shortcut-definitions.ts +++ b/packages/shared/ui/src/shortcut-definitions.ts @@ -7,6 +7,7 @@ export { formatKeysVerbose, withShortcut, detectPlatform, + isPrimaryModifier, SCOPE_PRIORITY, registry, scopeTracker,