Skip to content
50 changes: 32 additions & 18 deletions apps/code/src/renderer/components/GlobalEventHandlers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import { useSidebarStore } from "@features/sidebar/stores/sidebarStore";
import { useTasks } from "@features/tasks/hooks/useTasks";
import { useFocusWorkspace } from "@features/workspace/hooks/useFocusWorkspace";
import { useWorkspaces } from "@features/workspace/hooks/useWorkspace";
import { useShortcut } from "@hooks/useShortcut";
import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts";
import { useTRPC } from "@renderer/trpc";
import { isMac } from "@renderer/utils/platform";
import type { Task } from "@shared/types";
import { useCommandMenuStore } from "@stores/commandMenuStore";
import { useNavigationStore } from "@stores/navigationStore";
Expand Down Expand Up @@ -157,33 +159,43 @@ export function GlobalEventHandlers({
preventDefault: true,
} as const;

useHotkeys(SHORTCUTS.COMMAND_MENU, onToggleCommandMenu, {
const commandMenuKey = useShortcut("command-menu");
const newTaskKey = useShortcut("new-task");
const settingsKey = useShortcut("settings");
const goBackKey = useShortcut("go-back");
const goForwardKey = useShortcut("go-forward");
const toggleLeftSidebarKey = useShortcut("toggle-left-sidebar");
const toggleReviewPanelKey = useShortcut("toggle-review-panel");
const shortcutsSheetKey = useShortcut("shortcuts");
const inboxKey = useShortcut("inbox");
const prevTaskKey = useShortcut("prev-task");
const nextTaskKey = useShortcut("next-task");
const toggleFocusKey = useShortcut("toggle-focus");

useHotkeys(commandMenuKey, onToggleCommandMenu, {
...globalOptions,
enabled: !commandMenuOpen,
});
useHotkeys(SHORTCUTS.NEW_TASK, handleFocusTaskMode, globalOptions);
useHotkeys(SHORTCUTS.SETTINGS, handleOpenSettings, globalOptions);
useHotkeys(SHORTCUTS.GO_BACK, goBack, globalOptions);
useHotkeys(SHORTCUTS.GO_FORWARD, goForward, globalOptions);
useHotkeys(newTaskKey, handleFocusTaskMode, globalOptions);
useHotkeys(settingsKey, handleOpenSettings, globalOptions);
useHotkeys(goBackKey, goBack, globalOptions);
useHotkeys(goForwardKey, goForward, globalOptions);

const handleToggleReview = useCallback(() => {
if (!currentTaskId) return;
const mode = getReviewMode(currentTaskId);
setReviewMode(currentTaskId, mode === "closed" ? "split" : "closed");
}, [currentTaskId, getReviewMode, setReviewMode]);

useHotkeys(SHORTCUTS.TOGGLE_LEFT_SIDEBAR, toggleLeftSidebar, globalOptions);
useHotkeys(SHORTCUTS.TOGGLE_REVIEW_PANEL, handleToggleReview, globalOptions);
useHotkeys(SHORTCUTS.SHORTCUTS_SHEET, onToggleShortcutsSheet, globalOptions);
useHotkeys(SHORTCUTS.INBOX, navigateToInbox, globalOptions);
useHotkeys(SHORTCUTS.PREV_TASK, handlePrevTask, globalOptions, [
handlePrevTask,
]);
useHotkeys(SHORTCUTS.NEXT_TASK, handleNextTask, globalOptions, [
handleNextTask,
]);
useHotkeys(toggleLeftSidebarKey, toggleLeftSidebar, globalOptions);
useHotkeys(toggleReviewPanelKey, handleToggleReview, globalOptions);
useHotkeys(shortcutsSheetKey, onToggleShortcutsSheet, globalOptions);
useHotkeys(inboxKey, navigateToInbox, globalOptions);
useHotkeys(prevTaskKey, handlePrevTask, globalOptions, [handlePrevTask]);
useHotkeys(nextTaskKey, handleNextTask, globalOptions, [handleNextTask]);

useHotkeys(
SHORTCUTS.TOGGLE_FOCUS,
toggleFocusKey,
handleToggleFocus,
{
...globalOptions,
Expand All @@ -192,11 +204,13 @@ export function GlobalEventHandlers({
[handleToggleFocus],
);

// Task switching with mod+1-9
// Task switching with mod+1-9. On macOS, Ctrl+1..9 is reserved for
// SWITCH_TAB (panel tabs), so ignore plain-Ctrl there; on Windows/Linux,
// Ctrl IS mod, so the same event must trigger task switching.
useHotkeys(
SHORTCUTS.SWITCH_TASK,
(event, handler) => {
if (event.ctrlKey && !event.metaKey) return;
if (isMac && event.ctrlKey && !event.metaKey) return;

const keyPressed = handler.keys?.[0];
if (!keyPressed) return;
Expand Down
138 changes: 84 additions & 54 deletions apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,19 @@
import { Box, Dialog, Flex, Text } from "@radix-ui/themes";
import { Keycap } from "@components/Keycap";
import { ShortcutRecorder } from "@components/ShortcutRecorder";
import { Tooltip } from "@components/ui/Tooltip";
import { useShortcut } from "@hooks/useShortcut";
import { Box, Button, Dialog, Flex, Text } from "@radix-ui/themes";
import {
CATEGORY_LABELS,
type ConfigurableShortcutId,
formatHotkeyParts,
getShortcutsByCategory,
type ShortcutCategory,
} from "@renderer/constants/keyboard-shortcuts";
import { useMemo, useState } from "react";
import { useKeybindingsStore } from "@stores/keybindingsStore";
import { useMemo } from "react";
import { useHotkeys } from "react-hotkeys-hook";

function Keycap({ label, size = "md" }: { label: string; size?: "sm" | "md" }) {
const [pressed, setPressed] = useState(false);
const isSmall = size === "sm";
const minW = isSmall ? "22px" : "28px";
const h = isSmall ? "22px" : "28px";
const fontSize = isSmall ? "11px" : "13px";
const shadowSize = isSmall ? "2px" : "3px";

return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation
<span
role="presentation"
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
style={{
minWidth: minW,
height: h,
fontSize,
fontFamily: "system-ui, -apple-system, sans-serif",
lineHeight: 1,
borderBottomWidth: pressed ? "1px" : shadowSize,
borderBottomColor: "var(--gray-7)",
transform: pressed
? `translateY(${isSmall ? "1px" : "2px"})`
: "translateY(0)",
transition:
"transform 80ms ease-out, border-bottom-width 80ms ease-out",
}}
className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)"
>
{label}
</span>
);
}

interface KeyboardShortcutsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
Expand All @@ -65,9 +35,9 @@ export function KeyboardShortcutsSheet({
<Dialog.Content
maxWidth="600px"
onEscapeKeyDown={(e) => e.preventDefault()}
className="max-h-[80vh] overflow-hidden"
className="!pb-0 flex max-h-[80vh] flex-col overflow-hidden"
>
<Flex align="start" justify="between" className="relative">
<Flex align="start" justify="between" className="shrink-0 pb-2">
<ShortcutsHeader />
<button
type="button"
Expand All @@ -78,16 +48,51 @@ export function KeyboardShortcutsSheet({
</button>
</Flex>

<Box className="max-h-[calc(80vh-120px)] overflow-y-auto pr-[8px]">
<Box className="min-h-0 flex-1 overflow-y-auto pr-[8px]">
<KeyboardShortcutsList />
{/* Bottom padding so list content doesn't sit behind the sticky footer */}
<Box className="h-[56px]" />
</Box>

<ResetAllFooter />
</Dialog.Content>
</Dialog.Root>
);
}

function ResetAllFooter() {
const hasCustomBindings = useKeybindingsStore((s) =>
Object.keys(s.customKeybindings).some(
(k) =>
(s.customKeybindings[k as ConfigurableShortcutId]?.length ?? 0) > 0,
),
);
const resetAll = useKeybindingsStore((s) => s.resetAll);

if (!hasCustomBindings) return null;

return (
<Flex
justify="center"
align="center"
className="shrink-0 border-(--gray-4) border-t py-3"
>
<Button
variant="ghost"
color="gray"
size="1"
onClick={resetAll}
className="cursor-pointer"
>
Reset all shortcuts to defaults
</Button>
</Flex>
);
}

function ShortcutsHeader() {
const triggerParts = formatHotkeyParts("mod+/");
const shortcutsKey = useShortcut("shortcuts");
const triggerParts = formatHotkeyParts(shortcutsKey);

return (
<Box mb="4">
Expand Down Expand Up @@ -148,14 +153,30 @@ export function KeyboardShortcutsList() {
key={shortcut.id}
align="center"
justify="between"
gap="3"
px="3"
className="border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)"
className="group border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)"
>
<Text className="text-sm">{shortcut.description}</Text>
<ShortcutKeys
keys={shortcut.keys}
alternateKeys={shortcut.alternateKeys}
/>
<Flex direction="column" gap="0" className="min-w-0 flex-1">
<Text className="text-sm">{shortcut.description}</Text>
{shortcut.context && (
<Text color="gray" className="text-[11px]">
{shortcut.context}
</Text>
)}
</Flex>
<div className="shrink-0">
{shortcut.configurable ? (
<ShortcutRecorder
id={shortcut.id as ConfigurableShortcutId}
/>
) : (
<ShortcutKeys
keys={shortcut.keys}
alternateKeys={shortcut.alternateKeys}
/>
)}
</div>
</Flex>
))}
</Box>
Expand All @@ -168,7 +189,6 @@ export function KeyboardShortcutsList() {

function SingleShortcutKeys({ keys }: { keys: string }) {
const parts = formatHotkeyParts(keys);

return (
<Flex gap="1" align="center">
{parts.map((part) => (
Expand All @@ -185,17 +205,27 @@ function ShortcutKeys({
keys: string;
alternateKeys?: string;
}) {
if (!alternateKeys) {
return <SingleShortcutKeys keys={keys} />;
}

return (
const inner = alternateKeys ? (
<Flex gap="1" align="center">
<SingleShortcutKeys keys={keys} />
<Text color="gray" className="text-[13px]">
or
</Text>
<SingleShortcutKeys keys={alternateKeys} />
</Flex>
) : (
<SingleShortcutKeys keys={keys} />
);

return (
<Tooltip
content="This shortcut cannot be customized"
side="left"
delayDuration={400}
>
<div className="cursor-default rounded-(--radius-1) p-[2px] transition-colors hover:bg-(--gray-4)">
{inner}
</div>
</Tooltip>
);
}
42 changes: 42 additions & 0 deletions apps/code/src/renderer/components/Keycap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useState } from "react";

interface KeycapProps {
label: string;
size?: "sm" | "md";
}

export function Keycap({ label, size = "md" }: KeycapProps) {
const [pressed, setPressed] = useState(false);
const isSmall = size === "sm";
const minW = isSmall ? "22px" : "28px";
const h = isSmall ? "22px" : "28px";
const fontSize = isSmall ? "11px" : "13px";
const shadowSize = isSmall ? "2px" : "3px";

return (
// biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation
<span
role="presentation"
onMouseDown={() => setPressed(true)}
onMouseUp={() => setPressed(false)}
onMouseLeave={() => setPressed(false)}
style={{
minWidth: minW,
height: h,
fontSize,
fontFamily: "system-ui, -apple-system, sans-serif",
lineHeight: 1,
borderBottomWidth: pressed ? "1px" : shadowSize,
borderBottomColor: "var(--gray-7)",
transform: pressed
? `translateY(${isSmall ? "1px" : "2px"})`
: "translateY(0)",
transition:
"transform 80ms ease-out, border-bottom-width 80ms ease-out",
}}
className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)"
>
{label}
</span>
);
}
Loading