feat: expose hidden keybindings in settings for user customization (#521)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user