Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
456 changes: 456 additions & 0 deletions apps/code/tests/e2e/tests/shortcuts.spec.ts

Large diffs are not rendered by default.

155 changes: 155 additions & 0 deletions packages/ui/src/features/command/keyboard-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export const SHORTCUTS = {
SPACE_UP: "mod+up",
SPACE_DOWN: "mod+down",
FIND_IN_CONVERSATION: "mod+f",
FILE_PICKER: "mod+p",
MESSAGE_PREV: "alt+up",
MESSAGE_NEXT: "alt+down",
MESSAGE_JUMP: "mod+j",
BLUR: "escape",
SUBMIT_BLUR: "mod+enter",
} as const;
Expand All @@ -35,6 +39,7 @@ export interface KeyboardShortcut {
category: ShortcutCategory;
context?: string;
alternateKeys?: string;
configurable?: boolean;
}

export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
Expand All @@ -44,30 +49,35 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
description: "New task",
category: "general",
alternateKeys: "mod+t",
configurable: true,
},
{
id: "command-menu",
keys: SHORTCUTS.COMMAND_MENU,
description: "Open command menu",
category: "general",
configurable: true,
},
{
id: "settings",
keys: SHORTCUTS.SETTINGS,
description: "Open settings",
category: "general",
configurable: true,
},
{
id: "shortcuts",
keys: SHORTCUTS.SHORTCUTS_SHEET,
description: "Show keyboard shortcuts",
category: "general",
configurable: true,
},
{
id: "inbox",
keys: SHORTCUTS.INBOX,
description: "Open inbox",
category: "navigation",
configurable: true,
},
{
id: "switch-task",
Expand All @@ -81,49 +91,57 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
description: "Previous task",
category: "navigation",
alternateKeys: "ctrl+shift+tab",
configurable: true,
},
{
id: "next-task",
keys: "mod+shift+]",
description: "Next task",
category: "navigation",
alternateKeys: "ctrl+tab",
configurable: true,
},
{
id: "space-up",
keys: SHORTCUTS.SPACE_UP,
description: "Previous space",
category: "navigation",
configurable: true,
},
{
id: "space-down",
keys: SHORTCUTS.SPACE_DOWN,
description: "Next space",
category: "navigation",
configurable: true,
},
{
id: "go-back",
keys: SHORTCUTS.GO_BACK,
description: "Go back",
category: "navigation",
configurable: true,
},
{
id: "go-forward",
keys: SHORTCUTS.GO_FORWARD,
description: "Go forward",
category: "navigation",
configurable: true,
},
{
id: "toggle-left-sidebar",
keys: SHORTCUTS.TOGGLE_LEFT_SIDEBAR,
description: "Toggle left sidebar",
category: "navigation",
configurable: true,
},
{
id: "toggle-review-panel",
keys: SHORTCUTS.TOGGLE_REVIEW_PANEL,
description: "Toggle review panel",
category: "navigation",
configurable: true,
},
{
id: "switch-tab",
Expand All @@ -138,20 +156,31 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
description: "Close active tab",
category: "panels",
context: "Task detail",
configurable: true,
},
{
id: "open-in-editor",
keys: SHORTCUTS.OPEN_IN_EDITOR,
description: "Open in external editor",
category: "panels",
context: "Task detail",
configurable: true,
},
{
id: "copy-path",
keys: SHORTCUTS.COPY_PATH,
description: "Copy file path",
category: "panels",
context: "Task detail",
configurable: true,
},
{
id: "toggle-focus",
keys: SHORTCUTS.TOGGLE_FOCUS,
description: "Toggle focus mode",
category: "panels",
context: "Task detail (worktree)",
configurable: true,
},
{
id: "find-in-conversation",
Expand All @@ -160,26 +189,61 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
category: "panels",
context: "Task detail",
},
{
id: "file-picker",
keys: SHORTCUTS.FILE_PICKER,
description: "Open file picker",
category: "panels",
context: "Task detail",
configurable: true,
},
{
id: "message-prev",
keys: SHORTCUTS.MESSAGE_PREV,
description: "Previous message",
category: "panels",
context: "Task detail",
configurable: true,
},
{
id: "message-next",
keys: SHORTCUTS.MESSAGE_NEXT,
description: "Next message",
category: "panels",
context: "Task detail",
configurable: true,
},
{
id: "message-jump",
keys: SHORTCUTS.MESSAGE_JUMP,
description: "Jump to message",
category: "panels",
context: "Task detail",
configurable: true,
},
{
id: "paste-as-file",
keys: SHORTCUTS.PASTE_AS_FILE,
description: "Paste as file attachment",
category: "editor",
context: "Message editor",
configurable: true,
},
{
id: "prompt-history-prev",
keys: "up",
description: "Previous prompt (when input is empty)",
category: "editor",
context: "Message editor",
configurable: true,
},
{
id: "prompt-history-next",
keys: "down",
description: "Next prompt (when input is empty)",
category: "editor",
context: "Message editor",
configurable: true,
},
{
id: "editor-bold",
Expand Down Expand Up @@ -218,6 +282,62 @@ export const CATEGORY_LABELS: Record<ShortcutCategory, string> = {
editor: "Editor",
};

export const CONFIGURABLE_SHORTCUT_IDS = [
"command-menu",
"new-task",
"settings",
"shortcuts",
"inbox",
"prev-task",
"next-task",
"space-up",
"space-down",
"go-back",
"go-forward",
"toggle-left-sidebar",
"toggle-review-panel",
"close-tab",
"open-in-editor",
"copy-path",
"toggle-focus",
"file-picker",
"message-prev",
"message-next",
Comment on lines 282 to +305

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dual source of truth for configurability

CONFIGURABLE_SHORTCUT_IDS and the configurable: true flag on each KeyboardShortcut entry must be kept in sync manually. The flag controls which UI renderer (ShortcutRecorder vs ShortcutKeys) is shown in the sheet; the const array drives the TypeScript type and store logic. Adding a new shortcut requires updating both, but there is no compile-time or runtime guard that catches a mismatch — a shortcut could show ShortcutRecorder in the UI while the store type rejects its ID, or vice versa. Consider deriving one from the other: either generate CONFIGURABLE_SHORTCUT_IDS by filtering KEYBOARD_SHORTCUTS where configurable === true, or drop the configurable flag and compute it from membership in CONFIGURABLE_SHORTCUT_IDS.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/constants/keyboard-shortcuts.ts
Line: 282-305

Comment:
**Dual source of truth for configurability**

`CONFIGURABLE_SHORTCUT_IDS` and the `configurable: true` flag on each `KeyboardShortcut` entry must be kept in sync manually. The flag controls which UI renderer (`ShortcutRecorder` vs `ShortcutKeys`) is shown in the sheet; the const array drives the TypeScript type and store logic. Adding a new shortcut requires updating both, but there is no compile-time or runtime guard that catches a mismatch — a shortcut could show `ShortcutRecorder` in the UI while the store type rejects its ID, or vice versa. Consider deriving one from the other: either generate `CONFIGURABLE_SHORTCUT_IDS` by filtering `KEYBOARD_SHORTCUTS` where `configurable === true`, or drop the `configurable` flag and compute it from membership in `CONFIGURABLE_SHORTCUT_IDS`.

How can I resolve this? If you propose a fix, please make it concise.

"message-jump",
"paste-as-file",
"prompt-history-prev",
"prompt-history-next",
] as const;

export type ConfigurableShortcutId = (typeof CONFIGURABLE_SHORTCUT_IDS)[number];

export const DEFAULT_KEYBINDINGS: Record<ConfigurableShortcutId, string> = {
"command-menu": SHORTCUTS.COMMAND_MENU,
"new-task": SHORTCUTS.NEW_TASK,
settings: SHORTCUTS.SETTINGS,
shortcuts: SHORTCUTS.SHORTCUTS_SHEET,
inbox: SHORTCUTS.INBOX,
"prev-task": SHORTCUTS.PREV_TASK,
"next-task": SHORTCUTS.NEXT_TASK,
"space-up": SHORTCUTS.SPACE_UP,
"space-down": SHORTCUTS.SPACE_DOWN,
"go-back": SHORTCUTS.GO_BACK,
"go-forward": SHORTCUTS.GO_FORWARD,
"toggle-left-sidebar": SHORTCUTS.TOGGLE_LEFT_SIDEBAR,
"toggle-review-panel": SHORTCUTS.TOGGLE_REVIEW_PANEL,
"close-tab": SHORTCUTS.CLOSE_TAB,
"open-in-editor": SHORTCUTS.OPEN_IN_EDITOR,
"copy-path": SHORTCUTS.COPY_PATH,
"toggle-focus": SHORTCUTS.TOGGLE_FOCUS,
"file-picker": SHORTCUTS.FILE_PICKER,
"message-prev": SHORTCUTS.MESSAGE_PREV,
"message-next": SHORTCUTS.MESSAGE_NEXT,
"message-jump": SHORTCUTS.MESSAGE_JUMP,
"paste-as-file": SHORTCUTS.PASTE_AS_FILE,
"prompt-history-prev": "shift+up",
"prompt-history-next": "shift+down",
};

export function getShortcutsByCategory(): Record<
ShortcutCategory,
KeyboardShortcut[]
Expand All @@ -234,6 +354,41 @@ export function getShortcutsByCategory(): Record<
return grouped;
}

/**
* Convert a DOM KeyboardEvent to the normalised combo string used by the
* keybindings store (e.g. "mod+shift+v"). Returns null for bare modifier presses.
*/
export function eventToCombo(e: KeyboardEvent): string | null {
const bare = ["Meta", "Control", "Shift", "Alt"];
if (bare.includes(e.key)) return null;
if (!(e.metaKey || e.ctrlKey || e.altKey)) return null;

const parts: string[] = [];
if (e.metaKey || e.ctrlKey) parts.push("mod");
if (e.shiftKey) parts.push("shift");
if (e.altKey) parts.push("alt");
// Normalize "ArrowUp" → "up", "ArrowDown" → "down", etc. to match stored bindings.
parts.push(e.key.toLowerCase().replace(/^arrow/, ""));
return parts.join("+");
}

/**
* Like eventToCombo but also accepts shift-only combos (no ctrl/meta/alt required).
* Used inside Tiptap's handleKeyDown to match bindings such as "shift+up".
*/
export function tiptapEventToCombo(e: KeyboardEvent): string | null {
const bare = ["Meta", "Control", "Shift", "Alt"];
if (bare.includes(e.key)) return null;
if (!(e.metaKey || e.ctrlKey || e.altKey || e.shiftKey)) return null;

const parts: string[] = [];
if (e.metaKey || e.ctrlKey) parts.push("mod");
if (e.shiftKey) parts.push("shift");
if (e.altKey) parts.push("alt");
parts.push(e.key.toLowerCase().replace(/^arrow/, ""));
return parts.join("+");
}

function formatKey(key: string): string {
const k = key.trim().toLowerCase();
if (k === "mod") return isMac ? "⌘" : "Ctrl";
Expand Down
Loading