From ba80b14cf57ab02e72a704850a652303be071751 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 23 May 2026 12:38:45 +0200 Subject: [PATCH 1/2] fix: respect Ctrl as the primary modifier on Windows/Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several raw mouse/keyboard handlers gated on event.metaKey, which is the Super/Win key off macOS — so the Cmd-equivalent actions were dead on Windows and Linux. Add isPrimaryModifier() (Cmd on mac, Ctrl elsewhere, matching the shortcut registry's `mod` semantics) and route these handlers through it: - CreateTaskDialog: Ctrl+Enter / Ctrl+Shift+Enter now create the task - Terminal: Ctrl+Click opens URLs/files (browser / editor / external) - Kanban board & list: Ctrl+Click opens a task in the background Also fix hardcoded U+2318 hint strings so they render "Ctrl" off macOS via formatKeysForDisplay, add an enter to the display map, and show the correct Ctrl+Shift+C/V copy-paste hints in the terminal context menu. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/client/TerminalContextMenu.tsx | 13 ++++++--- .../task/src/client/CreateTaskDialog.tsx | 10 +++---- .../domains/tasks/src/client/KanbanColumn.tsx | 4 +-- .../tasks/src/client/KanbanListView.tsx | 3 ++- .../domains/terminal/src/client/Terminal.tsx | 27 ++++++++++++------- packages/shared/shortcuts/src/accelerator.ts | 2 ++ packages/shared/shortcuts/src/index.test.ts | 14 ++++++++++ packages/shared/shortcuts/src/index.ts | 2 +- packages/shared/shortcuts/src/platform.ts | 11 ++++++++ packages/shared/ui/src/index.ts | 2 ++ .../shared/ui/src/shortcut-definitions.ts | 1 + 11 files changed, 67 insertions(+), 22 deletions(-) diff --git a/packages/domains/task-terminals/src/client/TerminalContextMenu.tsx b/packages/domains/task-terminals/src/client/TerminalContextMenu.tsx index 0e4b47ea6..d981d69e2 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 27f52fc63..4538675fa 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 ccd2bc91c..2dccc5619 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 d1a7d13d8..48d7fca95 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 ec1bf9ac0..4f03e738a 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,9 @@ 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 urlHint = `— ${modKey}+Click open · ${formatKeysForDisplay('mod+shift')}+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 +562,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 +594,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 +613,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 434b69cb8..832a04c37 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 0a97b3361..ccb2a5b5c 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,19 @@ 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) + }) +}) + 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 f15dfa28b..754284de0 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 2ac5bebbe..2a7e1b54c 100644 --- a/packages/shared/shortcuts/src/platform.ts +++ b/packages/shared/shortcuts/src/platform.ts @@ -13,3 +13,14 @@ 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. + */ +export function isPrimaryModifier(e: { metaKey: boolean; ctrlKey: boolean }): boolean { + return detectPlatform() === 'mac' ? e.metaKey : e.ctrlKey +} diff --git a/packages/shared/ui/src/index.ts b/packages/shared/ui/src/index.ts index d765d1f81..c33666363 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 db023444f..6ff64769c 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, From 0c7ec1ee1305ee2b6141659218b36fe00158fff2 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 23 May 2026 12:52:34 +0200 Subject: [PATCH 2/2] fix: address review on Windows shortcut hints + modifier guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terminal URL hover hint: build the shift token separately so non-mac renders "Ctrl+Shift+Click" instead of the run-together "CtrlShift+Click" (formatKeysForDisplay joins parts without separators). - isPrimaryModifier: off macOS, require meta to be absent (`ctrlKey && !metaKey`), matching the registry's rejection of non-mac events with meta held — so it no longer fires for Ctrl+Win+Click / Ctrl+Win+Enter. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/domains/terminal/src/client/Terminal.tsx | 3 ++- packages/shared/shortcuts/src/index.test.ts | 5 +++++ packages/shared/shortcuts/src/platform.ts | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/domains/terminal/src/client/Terminal.tsx b/packages/domains/terminal/src/client/Terminal.tsx index 4f03e738a..57732c225 100644 --- a/packages/domains/terminal/src/client/Terminal.tsx +++ b/packages/domains/terminal/src/client/Terminal.tsx @@ -527,7 +527,8 @@ export const Terminal = forwardRef(function Termi } const modKey = formatKeysForDisplay('mod') - const urlHint = `— ${modKey}+Click open · ${formatKeysForDisplay('mod+shift')}+Click external` + 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 diff --git a/packages/shared/shortcuts/src/index.test.ts b/packages/shared/shortcuts/src/index.test.ts index ccb2a5b5c..d03c0b732 100644 --- a/packages/shared/shortcuts/src/index.test.ts +++ b/packages/shared/shortcuts/src/index.test.ts @@ -282,6 +282,11 @@ describe('isPrimaryModifier', () => { 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', () => { diff --git a/packages/shared/shortcuts/src/platform.ts b/packages/shared/shortcuts/src/platform.ts index 2a7e1b54c..84e1da20f 100644 --- a/packages/shared/shortcuts/src/platform.ts +++ b/packages/shared/shortcuts/src/platform.ts @@ -19,8 +19,9 @@ export function detectPlatform(): Platform { * 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. + * 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 + return detectPlatform() === 'mac' ? e.metaKey : e.ctrlKey && !e.metaKey }