feat: expose hidden keybindings in settings for user customization (#521)

This commit is contained in:
Carlos Florêncio
2026-04-01 23:29:49 +01:00
committed by GitHub
parent 0080ec9658
commit beb42acc29
12 changed files with 317 additions and 51 deletions
+2 -1
View File
@@ -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);
+6 -3
View File
@@ -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<CommandItem[]>(
() => [
@@ -24,7 +27,7 @@ export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) {
label: "Create New Task",
group: "Tasks",
icon: <IconPlus className="size-3.5" />,
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);
@@ -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;
@@ -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 (
<Tooltip>
@@ -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<boolean | undefined>,
submitKeyRef: React.RefObject<"enter" | "cmd_enter">,
onSubmitRef: React.RefObject<(() => void) | undefined>,
planModeEnabledRef: React.RefObject<boolean>,
onPlanModeChangeRef: React.RefObject<((enabled: boolean) => void) | undefined>,
) {
function useSubmitKeymap(refs: {
disabledRef: React.RefObject<boolean | undefined>;
submitKeyRef: React.RefObject<"enter" | "cmd_enter">;
onSubmitRef: React.RefObject<(() => void) | undefined>;
planModeEnabledRef: React.RefObject<boolean>;
onPlanModeChangeRef: React.RefObject<((enabled: boolean) => void) | undefined>;
keyboardShortcutsRef: React.RefObject<StoredShortcutOverrides | undefined>;
}) {
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
}, []);
@@ -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({
@@ -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<CommandItem[]>(
() => [
{
@@ -72,13 +74,13 @@ export const NewTaskButton = memo(function NewTaskButton({
label: "Create New Task",
group: "Tasks",
icon: <IconPlus className="size-3.5" />,
shortcut: SHORTCUTS.NEW_TASK,
shortcut: newTaskShortcut,
keywords: ["new", "create", "task", "add"],
action: () => setDialogOpen(true),
priority: 0,
},
],
[],
[newTaskShortcut],
);
useRegisterCommands(commands);
@@ -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<StoredShortcutOverrides | undefined>;
};
/** 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;
@@ -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 });
});
});
+14 -13
View File
@@ -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<typeof useAppStoreApi>,
previousFocusRef: React.MutableRefObject<Element | null>,
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
@@ -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);
});
});
+22 -6
View File
@@ -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<ConfigurableShortcutId, KeyboardShortcut> {
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<ConfigurableShortcutId, KeyboardShortcut>;
for (const id of ids) {
result[id] = getShortcut(id, overrides);
}
return result;
}