From beb42acc295fde9ce65fc5756079a47bb7c906d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 1 Apr 2026 23:29:49 +0100 Subject: [PATCH] feat: expose hidden keybindings in settings for user customization (#521) --- apps/web/components/command-panel.tsx | 3 +- apps/web/components/homepage-commands.tsx | 9 +- .../components/task/chat/chat-input-area.tsx | 5 +- .../task/chat/chat-input-toolbar.tsx | 9 +- .../components/task/chat/use-tiptap-editor.ts | 66 ++++++++--- .../components/task/passthrough-terminal.tsx | 8 +- .../components/task/task-session-sidebar.tsx | 8 +- .../task/use-passthrough-terminal.ts | 10 +- .../tests/settings/keyboard-shortcuts.spec.ts | 111 ++++++++++++++++++ apps/web/hooks/use-editor-keybinds.ts | 27 +++-- .../lib/keyboard/shortcut-overrides.test.ts | 84 +++++++++++++ apps/web/lib/keyboard/shortcut-overrides.ts | 28 ++++- 12 files changed, 317 insertions(+), 51 deletions(-) create mode 100644 apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts create mode 100644 apps/web/lib/keyboard/shortcut-overrides.test.ts diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index 000eec942d..0cfe0952d3 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -456,8 +456,9 @@ export function CommandPanel() { const searchShortcut = getShortcut("SEARCH", keyboardShortcuts); const fileSearchShortcut = getShortcut("FILE_SEARCH", keyboardShortcuts); + const commandPanelShortcut = getShortcut("COMMAND_PANEL", keyboardShortcuts); useKeyboardShortcut(searchShortcut, toggleCommands); - useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL, toggleCommands); + useKeyboardShortcut(commandPanelShortcut, toggleCommands); useKeyboardShortcut(SHORTCUTS.COMMAND_PANEL_SHIFT, toggleCommands); useKeyboardShortcut(fileSearchShortcut, openFileSearch); diff --git a/apps/web/components/homepage-commands.tsx b/apps/web/components/homepage-commands.tsx index 32ef9a1477..ff91757f8f 100644 --- a/apps/web/components/homepage-commands.tsx +++ b/apps/web/components/homepage-commands.tsx @@ -5,9 +5,10 @@ import { useRouter } from "next/navigation"; import { IconLayoutKanban, IconGitBranch, IconList, IconPlus } from "@tabler/icons-react"; import { useRegisterCommands } from "@/hooks/use-register-commands"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; import { linkToTasks } from "@/lib/links"; import type { CommandItem } from "@/lib/commands/types"; +import { useAppStore } from "@/components/state-provider"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; type HomepageCommandsProps = { onCreateTask: () => void; @@ -16,6 +17,8 @@ type HomepageCommandsProps = { export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { const router = useRouter(); const { onViewModeChange } = useKanbanDisplaySettings(); + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const newTaskShortcut = getShortcut("NEW_TASK", keyboardShortcuts); const commands = useMemo( () => [ @@ -24,7 +27,7 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { label: "Create New Task", group: "Tasks", icon: , - shortcut: SHORTCUTS.NEW_TASK, + shortcut: newTaskShortcut, keywords: ["new", "create", "task", "add"], action: onCreateTask, priority: 0, @@ -63,7 +66,7 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { action: () => router.push(linkToTasks()), }, ], - [onCreateTask, router, onViewModeChange], + [onCreateTask, router, onViewModeChange, newTaskShortcut], ); useRegisterCommands(commands); diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 6c381b4086..27df776efd 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -6,9 +6,9 @@ import { Button } from "@kandev/ui/button"; import { TodoIndicator } from "./todo-indicator"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; import { useMessageHandler } from "@/hooks/use-message-handler"; import { useAppStore } from "@/components/state-provider"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { type ContextFile } from "@/lib/state/context-files-store"; import { ChatInputContainer, @@ -185,8 +185,9 @@ export function useChatPanelHandlers( chatInputRef.current?.focusInput(); }, [chatInputRef]); + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); useKeyboardShortcut( - SHORTCUTS.FOCUS_INPUT, + getShortcut("FOCUS_INPUT", keyboardShortcuts), useCallback( (event: KeyboardEvent) => { const el = document.activeElement; diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index a5b29251f0..632d414599 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -21,6 +21,9 @@ import { cn } from "@/lib/utils"; import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; +import { formatShortcut } from "@/lib/keyboard/utils"; +import { useAppStore } from "@/components/state-provider"; import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display"; import { SessionsDropdown } from "@/components/task/sessions-dropdown"; import { ModelSelector } from "@/components/task/model-selector"; @@ -156,9 +159,11 @@ function PlanToggleButton({ planModeAvailable: boolean; onPlanModeChange: (enabled: boolean) => void; }) { + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const planModeShortcutLabel = formatShortcut(getShortcut("TOGGLE_PLAN_MODE", keyboardShortcuts)); const tooltip = planModeAvailable - ? "Toggle plan mode (Shift+Tab) — Agent collaborates on the plan without implementing changes" - : "Toggle plan layout (Shift+Tab) — View and edit the plan (agent cannot read/write it without MCP)"; + ? `Toggle plan mode (${planModeShortcutLabel}) — Agent collaborates on the plan without implementing changes` + : `Toggle plan layout (${planModeShortcutLabel}) — View and edit the plan (agent cannot read/write it without MCP)`; return ( diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index dc64c21082..02f6345f40 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -8,6 +8,8 @@ import Text from "@tiptap/extension-text"; import HardBreak from "@tiptap/extension-hard-break"; import History from "@tiptap/extension-history"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { matchesShortcut } from "@/lib/keyboard/utils"; +import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { Extension, isNodeEmpty } from "@tiptap/core"; @@ -15,6 +17,7 @@ import Code from "@tiptap/extension-code"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { common, createLowlight } from "lowlight"; import { cn } from "@/lib/utils"; +import { useAppStore } from "@/components/state-provider"; import { getChatDraftContent, setChatDraftContent } from "@/lib/local-storage"; import { getMarkdownText, textToHtml, handleEditorPaste } from "./tiptap-helpers"; import { CodeBlockView } from "./tiptap-code-block-view"; @@ -124,6 +127,8 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) { const sessionIdRef = useRef(opts.sessionId); const planModeEnabledRef = useRef(opts.planModeEnabled); const onPlanModeChangeRef = useRef(opts.onPlanModeChange); + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const keyboardShortcutsRef = useRef(keyboardShortcuts); useLayoutEffect(() => { onSubmitRef.current = opts.onSubmit; submitKeyRef.current = opts.submitKey; @@ -133,6 +138,7 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) { sessionIdRef.current = opts.sessionId; planModeEnabledRef.current = opts.planModeEnabled; onPlanModeChangeRef.current = opts.onPlanModeChange; + keyboardShortcutsRef.current = keyboardShortcuts; }); return { onSubmitRef, @@ -143,6 +149,7 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) { sessionIdRef, planModeEnabledRef, onPlanModeChangeRef, + keyboardShortcutsRef, }; } @@ -162,13 +169,14 @@ export function useTipTapEditor(opts: UseTipTapEditorOptions) { ref, } = opts; const refs = useTipTapRefs(opts); - const SubmitKeymap = useSubmitKeymap( - refs.disabledRef, - refs.submitKeyRef, - refs.onSubmitRef, - refs.planModeEnabledRef, - refs.onPlanModeChangeRef, - ); + const SubmitKeymap = useSubmitKeymap({ + disabledRef: refs.disabledRef, + submitKeyRef: refs.submitKeyRef, + onSubmitRef: refs.onSubmitRef, + planModeEnabledRef: refs.planModeEnabledRef, + onPlanModeChangeRef: refs.onPlanModeChangeRef, + keyboardShortcutsRef: refs.keyboardShortcutsRef, + }); const isSyncingRef = useRef(false); const initialSyncDoneRef = useRef(false); const editor = useEditor({ @@ -343,13 +351,22 @@ function syncEditorValue({ // ── Submit keymap hook ────────────────────────────────────────────── -function useSubmitKeymap( - disabledRef: React.RefObject, - submitKeyRef: React.RefObject<"enter" | "cmd_enter">, - onSubmitRef: React.RefObject<(() => void) | undefined>, - planModeEnabledRef: React.RefObject, - onPlanModeChangeRef: React.RefObject<((enabled: boolean) => void) | undefined>, -) { +function useSubmitKeymap(refs: { + disabledRef: React.RefObject; + submitKeyRef: React.RefObject<"enter" | "cmd_enter">; + onSubmitRef: React.RefObject<(() => void) | undefined>; + planModeEnabledRef: React.RefObject; + onPlanModeChangeRef: React.RefObject<((enabled: boolean) => void) | undefined>; + keyboardShortcutsRef: React.RefObject; +}) { + const { + disabledRef, + submitKeyRef, + onSubmitRef, + planModeEnabledRef, + onPlanModeChangeRef, + keyboardShortcutsRef, + } = refs; return useMemo(() => { return Extension.create({ name: "submitKeymap", @@ -371,12 +388,25 @@ function useSubmitKeymap( } return false; }, - "Shift-Tab": () => { - onPlanModeChangeRef.current?.(!planModeEnabledRef.current); - return true; - }, }; }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("planModeToggle"), + props: { + handleKeyDown: (_view, event) => { + const shortcut = getShortcut("TOGGLE_PLAN_MODE", keyboardShortcutsRef.current); + if (matchesShortcut(event, shortcut)) { + onPlanModeChangeRef.current?.(!planModeEnabledRef.current); + return true; + } + return false; + }, + }, + }), + ]; + }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index b493e2ddb4..1dd86ed1ed 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useRef, useCallback, useMemo, useState } from "react"; +import React, { useRef, useCallback, useEffect, useMemo, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { AttachAddon } from "@xterm/addon-attach"; @@ -110,6 +110,11 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { const sendInput = useSendInput(wsRef); const toggleBottomTerminal = useAppStore((s) => s.toggleBottomTerminal); + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const keyboardShortcutsRef = useRef(keyboardShortcuts); + useEffect(() => { + keyboardShortcutsRef.current = keyboardShortcuts; + }, [keyboardShortcuts]); useTerminalInit({ terminalRef: refs.terminalRef, xtermRef: refs.xtermRef, @@ -125,6 +130,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { fontSize: terminalFontSize ?? undefined, onToggleBottomTerminal: toggleBottomTerminal, sendInput, + keyboardShortcutsRef, }); useWebSocketConnection({ diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 5d8fcc1981..f3082110bb 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -12,7 +12,7 @@ import { IconPlus } from "@tabler/icons-react"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useRegisterCommands } from "@/hooks/use-register-commands"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; +import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import type { CommandItem } from "@/lib/commands/types"; import { replaceTaskUrl } from "@/lib/links"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; @@ -65,6 +65,8 @@ export const NewTaskButton = memo(function NewTaskButton({ }: NewTaskButtonProps) { const [dialogOpen, setDialogOpen] = useState(false); + const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const newTaskShortcut = getShortcut("NEW_TASK", keyboardShortcuts); const commands = useMemo( () => [ { @@ -72,13 +74,13 @@ export const NewTaskButton = memo(function NewTaskButton({ label: "Create New Task", group: "Tasks", icon: , - shortcut: SHORTCUTS.NEW_TASK, + shortcut: newTaskShortcut, keywords: ["new", "create", "task", "add"], action: () => setDialogOpen(true), priority: 0, }, ], - [], + [newTaskShortcut], ); useRegisterCommands(commands); diff --git a/apps/web/components/task/use-passthrough-terminal.ts b/apps/web/components/task/use-passthrough-terminal.ts index 1f953038e7..e685cf0db8 100644 --- a/apps/web/components/task/use-passthrough-terminal.ts +++ b/apps/web/components/task/use-passthrough-terminal.ts @@ -7,8 +7,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { getTerminalTheme } from "@/lib/theme/terminal-theme"; import { startReconnectLoop } from "./ws-reconnect"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; import { matchesShortcut } from "@/lib/keyboard/utils"; +import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; import { exposeBufferReader, clearBufferReader } from "./terminal-buffer-reader"; // Debug flag - set to true to see detailed logs @@ -36,12 +36,16 @@ export type TerminalInitOptions = { type TerminalKeyHandlerOptions = { onToggleBottomTerminal?: () => void; sendInput?: (data: string) => void; + /** Ref to keyboard shortcut overrides so the handler always reads the latest value. */ + keyboardShortcutsRef?: React.MutableRefObject; }; /** Handles app-level shortcuts and Cmd+Arrow→Home/End mapping for macOS. */ function createKeyEventHandler(options: TerminalKeyHandlerOptions) { return (event: KeyboardEvent): boolean => { - if (matchesShortcut(event, SHORTCUTS.BOTTOM_TERMINAL)) { + if ( + matchesShortcut(event, getShortcut("BOTTOM_TERMINAL", options.keyboardShortcutsRef?.current)) + ) { event.preventDefault(); if (event.type === "keydown" && options.onToggleBottomTerminal) { options.onToggleBottomTerminal(); @@ -190,6 +194,7 @@ export function useTerminalInit({ fontSize, onToggleBottomTerminal, sendInput, + keyboardShortcutsRef, }: TerminalInitHookOptions) { const refs = { xtermRef, @@ -222,6 +227,7 @@ export function useTerminalInit({ fontSize, onToggleBottomTerminal, sendInput, + keyboardShortcutsRef, }); onReady(); return true; diff --git a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts new file mode 100644 index 0000000000..b5f4574e30 --- /dev/null +++ b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Keyboard Shortcuts Settings", () => { + test.describe.configure({ retries: 1 }); + + test("settings page shows all configurable shortcuts", async ({ testPage }) => { + await testPage.goto("/settings/general"); + + // Original 3 shortcuts + await expect(testPage.getByTestId("shortcut-recorder-SEARCH")).toBeVisible({ timeout: 10_000 }); + await expect(testPage.getByTestId("shortcut-recorder-FILE_SEARCH")).toBeVisible(); + await expect(testPage.getByTestId("shortcut-recorder-QUICK_CHAT")).toBeVisible(); + + // Newly exposed shortcuts + await expect(testPage.getByTestId("shortcut-recorder-BOTTOM_TERMINAL")).toBeVisible(); + await expect(testPage.getByTestId("shortcut-recorder-TOGGLE_SIDEBAR")).toBeVisible(); + await expect(testPage.getByTestId("shortcut-recorder-COMMAND_PANEL")).toBeVisible(); + await expect(testPage.getByTestId("shortcut-recorder-NEW_TASK")).toBeVisible(); + await expect(testPage.getByTestId("shortcut-recorder-FOCUS_INPUT")).toBeVisible(); + await expect(testPage.getByTestId("shortcut-recorder-TOGGLE_PLAN_MODE")).toBeVisible(); + }); + + test("can record a new shortcut and persist it", async ({ testPage, apiClient, seedData }) => { + // Reset settings to defaults + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + keyboard_shortcuts: {}, + }); + + await testPage.goto("/settings/general"); + + // Find the BOTTOM_TERMINAL recorder and verify it shows the default (Cmd+J or Ctrl+J) + const recorder = testPage.getByTestId("shortcut-recorder-BOTTOM_TERMINAL"); + await expect(recorder).toBeVisible({ timeout: 10_000 }); + + // Click to start recording + await recorder.click(); + await expect(recorder.getByText("Press a key combo...")).toBeVisible({ timeout: 3_000 }); + + // Press a new key combo: Ctrl+Shift+T + await testPage.keyboard.press("Control+Shift+t"); + + // Verify the recorder displays the new combo (recording should have stopped) + await expect(recorder.getByText("Press a key combo...")).not.toBeVisible({ timeout: 3_000 }); + // The Kbd element should now show the new shortcut + await expect(recorder).toContainText("T", { timeout: 3_000 }); + + // Reload the page and verify the shortcut persisted + await testPage.goto("/settings/general"); + const recorderAfterReload = testPage.getByTestId("shortcut-recorder-BOTTOM_TERMINAL"); + await expect(recorderAfterReload).toBeVisible({ timeout: 10_000 }); + await expect(recorderAfterReload).toContainText("T"); + }); + + test("can reset a customized shortcut to default", async ({ testPage, apiClient, seedData }) => { + // Set a custom shortcut via API + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + keyboard_shortcuts: { + TOGGLE_SIDEBAR: { key: "x", modifiers: { ctrlOrCmd: true } }, + }, + }); + + await testPage.goto("/settings/general"); + + const recorder = testPage.getByTestId("shortcut-recorder-TOGGLE_SIDEBAR"); + await expect(recorder).toBeVisible({ timeout: 10_000 }); + + // Should show the custom shortcut (X) + await expect(recorder).toContainText("X", { timeout: 3_000 }); + + // Should have a reset button since it's customized + const row = recorder.locator(".."); + const resetButton = row.getByTitle("Reset to default"); + await expect(resetButton).toBeVisible(); + + // Click reset + await resetButton.click(); + + // Should now show the default (B for Cmd/Ctrl+B) + await expect(recorder).toContainText("B", { timeout: 3_000 }); + }); + + test("customized command panel shortcut opens the panel", async ({ + testPage, + apiClient, + seedData, + }) => { + // Override SEARCH (command panel) shortcut to Ctrl+Shift+O + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + keyboard_shortcuts: { + SEARCH: { key: "o", modifiers: { ctrlOrCmd: true, shift: true } }, + }, + }); + + await testPage.goto("/"); + await testPage.waitForLoadState("networkidle"); + + // Press the NEW shortcut — command panel should open + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await testPage.keyboard.press(`${modifier}+Shift+o`); + + const dialog = testPage.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Close the dialog + await testPage.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible({ timeout: 3_000 }); + }); +}); diff --git a/apps/web/hooks/use-editor-keybinds.ts b/apps/web/hooks/use-editor-keybinds.ts index f5ed9a8869..715409a1e0 100644 --- a/apps/web/hooks/use-editor-keybinds.ts +++ b/apps/web/hooks/use-editor-keybinds.ts @@ -4,8 +4,8 @@ import { useEffect, useRef } from "react"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { useAppStoreApi } from "@/components/state-provider"; import { createUserShell } from "@/lib/api/domains/user-shell-api"; -import { SHORTCUTS } from "@/lib/keyboard/constants"; import { matchesShortcut } from "@/lib/keyboard/utils"; +import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides"; import type { DockviewApi } from "dockview-react"; function handleTabNavigation(e: KeyboardEvent, api: DockviewApi) { @@ -76,15 +76,13 @@ function isEditableTarget(e: KeyboardEvent): boolean { ); } -/** Returns true if the event matches Cmd/Ctrl + key (no shift). */ -function isCmdKey(e: KeyboardEvent, code: string): boolean { - return (e.metaKey || e.ctrlKey) && !e.shiftKey && e.code === code; -} - -function handleLayoutToggle(e: KeyboardEvent): boolean { +function handleLayoutToggle( + e: KeyboardEvent, + overrides: StoredShortcutOverrides | undefined, +): boolean { if (isEditableTarget(e)) return false; - if (isCmdKey(e, "KeyB")) { + if (matchesShortcut(e, getShortcut("TOGGLE_SIDEBAR", overrides))) { e.preventDefault(); e.stopPropagation(); useDockviewStore.getState().toggleSidebar(); @@ -98,13 +96,14 @@ function handleBottomTerminal( e: KeyboardEvent, appStore: ReturnType, previousFocusRef: React.MutableRefObject, + overrides: StoredShortcutOverrides | undefined, ): boolean { - // Cmd/Ctrl+J — toggle bottom terminal panel - // Note: no isEditableTarget guard here. Ctrl+J is not a standard text + // Toggle bottom terminal panel (default: Cmd/Ctrl+J). + // Note: no isEditableTarget guard here. The default binding is not a standard text // editing shortcut, and we must preventDefault even when an xterm textarea // is focused — otherwise the un-prevented event causes escape-sequence // artifacts (e.g. trailing "R" from cursor-position reports during resize). - if (matchesShortcut(e, SHORTCUTS.BOTTOM_TERMINAL)) { + if (matchesShortcut(e, getShortcut("BOTTOM_TERMINAL", overrides))) { e.preventDefault(); e.stopPropagation(); @@ -154,6 +153,8 @@ export function useEditorKeybinds() { const api = useDockviewStore.getState().api; if (!api) return; + const overrides = appStore.getState().userSettings.keyboardShortcuts; + const isTabNav = (e.metaKey || e.ctrlKey) && e.shiftKey && @@ -176,8 +177,8 @@ export function useEditorKeybinds() { return; } - if (handleLayoutToggle(e)) return; - handleBottomTerminal(e, appStore, previousFocusRef); + if (handleLayoutToggle(e, overrides)) return; + handleBottomTerminal(e, appStore, previousFocusRef, overrides); }; // Use capture phase so we receive events before xterm.js diff --git a/apps/web/lib/keyboard/shortcut-overrides.test.ts b/apps/web/lib/keyboard/shortcut-overrides.test.ts new file mode 100644 index 0000000000..ea5e3b0684 --- /dev/null +++ b/apps/web/lib/keyboard/shortcut-overrides.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { SHORTCUTS } from "./constants"; +import { + CONFIGURABLE_SHORTCUTS, + getShortcut, + resolveAllShortcuts, + type ConfigurableShortcutId, +} from "./shortcut-overrides"; + +describe("CONFIGURABLE_SHORTCUTS", () => { + it("contains all expected shortcut IDs", () => { + const ids = Object.keys(CONFIGURABLE_SHORTCUTS); + expect(ids).toContain("SEARCH"); + expect(ids).toContain("FILE_SEARCH"); + expect(ids).toContain("QUICK_CHAT"); + expect(ids).toContain("BOTTOM_TERMINAL"); + expect(ids).toContain("TOGGLE_SIDEBAR"); + expect(ids).toContain("COMMAND_PANEL"); + expect(ids).toContain("NEW_TASK"); + expect(ids).toContain("FOCUS_INPUT"); + expect(ids).toContain("TOGGLE_PLAN_MODE"); + expect(ids).toHaveLength(9); + }); + + it("each entry has a label and default matching SHORTCUTS", () => { + expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.label).toBe("Toggle Bottom Terminal"); + expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.default).toBe(SHORTCUTS.BOTTOM_TERMINAL); + + expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.label).toBe("Toggle Sidebar"); + expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.default).toBe(SHORTCUTS.TOGGLE_SIDEBAR); + + expect(CONFIGURABLE_SHORTCUTS.COMMAND_PANEL.label).toBe("Command Panel (Alt)"); + expect(CONFIGURABLE_SHORTCUTS.COMMAND_PANEL.default).toBe(SHORTCUTS.COMMAND_PANEL); + + expect(CONFIGURABLE_SHORTCUTS.NEW_TASK.label).toBe("New Task"); + expect(CONFIGURABLE_SHORTCUTS.NEW_TASK.default).toBe(SHORTCUTS.NEW_TASK); + + expect(CONFIGURABLE_SHORTCUTS.FOCUS_INPUT.label).toBe("Focus Chat Input"); + expect(CONFIGURABLE_SHORTCUTS.FOCUS_INPUT.default).toBe(SHORTCUTS.FOCUS_INPUT); + + expect(CONFIGURABLE_SHORTCUTS.TOGGLE_PLAN_MODE.label).toBe("Toggle Plan Mode"); + expect(CONFIGURABLE_SHORTCUTS.TOGGLE_PLAN_MODE.default).toBe(SHORTCUTS.TOGGLE_PLAN_MODE); + }); +}); + +describe("getShortcut", () => { + it("returns default when no overrides provided", () => { + expect(getShortcut("BOTTOM_TERMINAL")).toBe(SHORTCUTS.BOTTOM_TERMINAL); + expect(getShortcut("TOGGLE_SIDEBAR")).toBe(SHORTCUTS.TOGGLE_SIDEBAR); + }); + + it("returns default when override does not contain the ID", () => { + expect(getShortcut("BOTTOM_TERMINAL", {})).toBe(SHORTCUTS.BOTTOM_TERMINAL); + }); + + it("returns override when present", () => { + const override = { key: "t", modifiers: { ctrlOrCmd: true, shift: true } }; + const result = getShortcut("BOTTOM_TERMINAL", { BOTTOM_TERMINAL: override }); + expect(result).toEqual(override); + }); + + it("does not affect other shortcuts when one is overridden", () => { + const overrides = { BOTTOM_TERMINAL: { key: "x", modifiers: { ctrlOrCmd: true } } }; + expect(getShortcut("TOGGLE_SIDEBAR", overrides)).toBe(SHORTCUTS.TOGGLE_SIDEBAR); + }); +}); + +describe("resolveAllShortcuts", () => { + it("returns all defaults when no overrides provided", () => { + const result = resolveAllShortcuts(); + const ids = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[]; + for (const id of ids) { + expect(result[id]).toBe(CONFIGURABLE_SHORTCUTS[id].default); + } + }); + + it("applies overrides while keeping other defaults", () => { + const override = { key: "m", modifiers: { alt: true } }; + const result = resolveAllShortcuts({ TOGGLE_SIDEBAR: override }); + expect(result.TOGGLE_SIDEBAR).toEqual(override); + expect(result.BOTTOM_TERMINAL).toBe(SHORTCUTS.BOTTOM_TERMINAL); + expect(result.SEARCH).toBe(SHORTCUTS.SEARCH); + }); +}); diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts index 4471ebc751..0dc7d1e5eb 100644 --- a/apps/web/lib/keyboard/shortcut-overrides.ts +++ b/apps/web/lib/keyboard/shortcut-overrides.ts @@ -1,6 +1,15 @@ import { SHORTCUTS, type KeyboardShortcut } from "./constants"; -export type ConfigurableShortcutId = "SEARCH" | "FILE_SEARCH" | "QUICK_CHAT"; +export type ConfigurableShortcutId = + | "SEARCH" + | "FILE_SEARCH" + | "QUICK_CHAT" + | "BOTTOM_TERMINAL" + | "TOGGLE_SIDEBAR" + | "COMMAND_PANEL" + | "NEW_TASK" + | "FOCUS_INPUT" + | "TOGGLE_PLAN_MODE"; export type StoredShortcutOverrides = Record< string, @@ -14,6 +23,12 @@ export const CONFIGURABLE_SHORTCUTS: Record< SEARCH: { label: "Command Panel", default: SHORTCUTS.SEARCH }, FILE_SEARCH: { label: "File Search", default: SHORTCUTS.FILE_SEARCH }, QUICK_CHAT: { label: "Quick Chat", default: SHORTCUTS.QUICK_CHAT }, + BOTTOM_TERMINAL: { label: "Toggle Bottom Terminal", default: SHORTCUTS.BOTTOM_TERMINAL }, + TOGGLE_SIDEBAR: { label: "Toggle Sidebar", default: SHORTCUTS.TOGGLE_SIDEBAR }, + COMMAND_PANEL: { label: "Command Panel (Alt)", default: SHORTCUTS.COMMAND_PANEL }, + NEW_TASK: { label: "New Task", default: SHORTCUTS.NEW_TASK }, + FOCUS_INPUT: { label: "Focus Chat Input", default: SHORTCUTS.FOCUS_INPUT }, + TOGGLE_PLAN_MODE: { label: "Toggle Plan Mode", default: SHORTCUTS.TOGGLE_PLAN_MODE }, }; export function getShortcut( @@ -28,9 +43,10 @@ export function getShortcut( export function resolveAllShortcuts( overrides?: StoredShortcutOverrides, ): Record { - return { - SEARCH: getShortcut("SEARCH", overrides), - FILE_SEARCH: getShortcut("FILE_SEARCH", overrides), - QUICK_CHAT: getShortcut("QUICK_CHAT", overrides), - }; + const ids = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[]; + const result = {} as Record; + for (const id of ids) { + result[id] = getShortcut(id, overrides); + } + return result; }