diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx
index c3868e55b..a95dadff6 100644
--- a/packages/app/src/components/agent-input-area.tsx
+++ b/packages/app/src/components/agent-input-area.tsx
@@ -594,12 +594,14 @@ export function AgentInputArea({
)}
-
- Interrupt
- {dictationCancelKeys ? : null}
-
-
-
+
+ Interrupt
+ {dictationCancelKeys ? (
+
+ ) : null}
+
+
+
) : null;
const rightContent = (
@@ -626,7 +628,9 @@ export function AgentInputArea({
Voice mode
- {voiceToggleKeys && }
+ {voiceToggleKeys ? (
+
+ ) : null}
diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx
index 4ff7af037..b243ace72 100644
--- a/packages/app/src/components/command-center.tsx
+++ b/packages/app/src/components/command-center.tsx
@@ -178,7 +178,7 @@ export function CommandCenter() {
{action.shortcutKeys ? (
-
+
) : null}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index b8ef91dac..88d95589b 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -565,7 +565,7 @@ function MobileSidebar({
Add project
- {newAgentKeys && }
+ {newAgentKeys ? : null}
@@ -745,7 +745,7 @@ function DesktopSidebar({
Add project
- {newAgentKeys ? : null}
+ {newAgentKeys ? : null}
diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx
index 05bfd25e4..8519126b8 100644
--- a/packages/app/src/components/message-input.tsx
+++ b/packages/app/src/components/message-input.tsx
@@ -1013,7 +1013,11 @@ export const MessageInput = forwardRef(funct
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
) : null}
@@ -1039,7 +1043,9 @@ export const MessageInput = forwardRef(funct
Queue
- {queueKeys ? : null}
+ {queueKeys ? (
+
+ ) : null}
@@ -1062,7 +1068,7 @@ export const MessageInput = forwardRef(funct
Send
- {sendKeys ? : null}
+ {sendKeys ? : null}
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index ae6ff3d7a..59be73978 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -150,7 +150,7 @@ interface WorkspaceRowInnerProps {
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
- archiveShortcutKeys?: ShortcutKey[] | null;
+ archiveShortcutKeys?: ShortcutKey[][] | null;
}
function resolveStatusDotColor(input: {
@@ -322,7 +322,7 @@ function NewWorktreeButton({
New worktree
{showShortcutHint && newWorktreeKeys ? (
-
+
) : null}
@@ -842,7 +842,7 @@ function WorkspaceRowInner({
}
- trailing={archiveShortcutKeys ? : null}
+ trailing={archiveShortcutKeys ? : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx
index 60097f763..a1e8b7e06 100644
--- a/packages/app/src/components/ui/shortcut.tsx
+++ b/packages/app/src/components/ui/shortcut.tsx
@@ -6,22 +6,49 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
export function Shortcut({
keys,
+ chord,
style,
textStyle,
}: {
- keys: ShortcutKey[];
+ keys?: ShortcutKey[];
+ chord?: ShortcutKey[][];
style?: StyleProp;
textStyle?: StyleProp;
}): ReactElement {
+ const displayChord = chord ?? (keys ? [keys] : []);
+ const shortcutOs = getShortcutOs();
+ const singleCombo = displayChord[0];
+
+ if (!singleCombo) {
+ return ;
+ }
+
+ if (displayChord.length === 1) {
+ return (
+
+ {formatShortcut(singleCombo, shortcutOs)}
+
+ );
+ }
+
return (
-
- {formatShortcut(keys, getShortcutOs())}
+
+ {displayChord.map(function (combo, index) {
+ return (
+
+
+ {formatShortcut(combo, shortcutOs)}
+
+ {index < displayChord.length - 1 ? → : null}
+
+ );
+ })}
);
}
const styles = StyleSheet.create((theme) => ({
- root: {
+ badge: {
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
@@ -29,6 +56,21 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
+ sequence: {
+ flexDirection: "row",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: theme.spacing[1],
+ },
+ sequenceItem: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ },
+ separator: {
+ fontSize: theme.fontSize.xs,
+ color: theme.colors.foregroundMuted,
+ },
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts
index ac8c3b432..6c4857e1b 100644
--- a/packages/app/src/hooks/use-command-center.ts
+++ b/packages/app/src/hooks/use-command-center.ts
@@ -13,7 +13,7 @@ import {
} from "@/utils/command-center-focus-restore";
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
-import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
+import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -91,7 +91,7 @@ export type CommandCenterActionItem = {
title: string;
icon?: "plus" | "settings";
route?: Href;
- shortcutKeys?: ShortcutKey[];
+ shortcutKeys?: ShortcutKey[][];
};
export type CommandCenterItem =
@@ -107,7 +107,7 @@ export type CommandCenterItem =
function resolveActionShortcutKeys(
actionId: string | undefined,
overrides: Record,
-): ShortcutKey[] | undefined {
+): ShortcutKey[][] | undefined {
if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
@@ -115,8 +115,9 @@ function resolveActionShortcutKeys(
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
- if (override) return comboStringToShortcutKeys(override);
- return getDefaultKeysForAction(actionId, platform) ?? undefined;
+ if (override) return chordStringToShortcutKeys(override);
+ const defaultKeys = getDefaultKeysForAction(actionId, platform);
+ return defaultKeys ? [defaultKeys] : undefined;
}
export function useCommandCenter() {
diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts
index fe1b5865f..7434e2b9a 100644
--- a/packages/app/src/hooks/use-keyboard-shortcuts.ts
+++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts
@@ -1,4 +1,4 @@
-import { useEffect, useMemo } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsDesktop } from "@/constants/layout";
@@ -17,7 +17,11 @@ import {
} from "@/keyboard/actions";
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
-import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts";
+import {
+ type ChordState,
+ resolveKeyboardShortcut,
+ buildEffectiveBindings,
+} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -41,6 +45,11 @@ export function useKeyboardShortcuts({
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
+ const chordStateRef = useRef({
+ candidateIndices: [],
+ step: 0,
+ timeoutId: null,
+ });
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
const activeServerId =
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
@@ -271,6 +280,11 @@ export function useKeyboardShortcuts({
return;
}
+ const store = useKeyboardShortcutsStore.getState();
+ if (store.capturingShortcut) {
+ return;
+ }
+
const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
@@ -285,12 +299,11 @@ export function useKeyboardShortcuts({
}
}
- const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
- const match = resolveKeyboardShortcut({
+ const result = resolveKeyboardShortcut({
event,
context: {
isMac,
@@ -303,25 +316,41 @@ export function useKeyboardShortcuts({
toggleFileExplorer,
}),
},
+ chordState: chordStateRef.current,
+ onChordReset: () => {
+ chordStateRef.current = {
+ candidateIndices: [],
+ step: 0,
+ timeoutId: null,
+ };
+ },
bindings,
});
- if (!match) {
+
+ chordStateRef.current = result.nextChordState;
+
+ if (result.preventDefault) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (!result.match) {
return;
}
const handled = handleAction({
- action: match.action,
- payload: match.payload,
+ action: result.match.action,
+ payload: result.match.payload,
event,
});
if (!handled) {
return;
}
- if (match.preventDefault) {
+ if (result.match.preventDefault) {
event.preventDefault();
}
- if (match.stopPropagation) {
+ if (result.match.stopPropagation) {
event.stopPropagation();
}
};
@@ -345,6 +374,14 @@ export function useKeyboardShortcuts({
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
+ if (chordStateRef.current.timeoutId !== null) {
+ clearTimeout(chordStateRef.current.timeoutId);
+ chordStateRef.current = {
+ candidateIndices: [],
+ step: 0,
+ timeoutId: null,
+ };
+ }
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
diff --git a/packages/app/src/hooks/use-shortcut-keys.ts b/packages/app/src/hooks/use-shortcut-keys.ts
index 2cfa3044e..dd43ae9fc 100644
--- a/packages/app/src/hooks/use-shortcut-keys.ts
+++ b/packages/app/src/hooks/use-shortcut-keys.ts
@@ -1,12 +1,12 @@
import { useMemo } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut";
-import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
+import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
-export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
+export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
@@ -18,9 +18,10 @@ export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
const override = overrides[bindingId];
if (override) {
- return comboStringToShortcutKeys(override);
+ return chordStringToShortcutKeys(override);
}
- return getDefaultKeysForAction(actionId, platform);
+ const defaultKeys = getDefaultKeysForAction(actionId, platform);
+ return defaultKeys ? [defaultKeys] : null;
}, [actionId, overrides, isMac, isDesktop]);
}
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts
index 2e1d0a6a6..21bc67c72 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts
@@ -1,8 +1,11 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
buildKeyboardShortcutHelpSections,
+ buildEffectiveBindings,
resolveKeyboardShortcut,
+ type ChordState,
type KeyboardShortcutContext,
+ type ParsedShortcutBinding,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial): KeyboardEvent {
@@ -31,6 +34,30 @@ function shortcutContext(
};
}
+function initialChordState(): ChordState {
+ return {
+ candidateIndices: [],
+ step: 0,
+ timeoutId: null,
+ };
+}
+
+function resolveShortcut(input: {
+ event: Partial;
+ context?: Partial;
+ chordState?: ChordState;
+ onChordReset?: () => void;
+ bindings?: readonly ParsedShortcutBinding[];
+}) {
+ return resolveKeyboardShortcut({
+ event: keyboardEvent(input.event),
+ context: shortcutContext(input.context),
+ chordState: input.chordState ?? initialChordState(),
+ onChordReset: input.onChordReset ?? (() => undefined),
+ ...(input.bindings ? { bindings: input.bindings } : {}),
+ });
+}
+
function expectShortcutResolution(input: {
event: Partial;
context?: Partial;
@@ -39,29 +66,33 @@ function expectShortcutResolution(input: {
preventDefault?: boolean;
stopPropagation?: boolean;
}) {
- const match = resolveKeyboardShortcut({
- event: keyboardEvent(input.event),
- context: shortcutContext(input.context),
+ const result = resolveShortcut({
+ event: input.event,
+ context: input.context,
});
- expect(match?.action).toBe(input.action);
+ expect(result.match?.action).toBe(input.action);
if ("payload" in input) {
- expect(match?.payload).toEqual(input.payload);
+ expect(result.match?.payload).toEqual(input.payload);
}
- expect(match?.preventDefault).toBe(input.preventDefault ?? true);
- expect(match?.stopPropagation).toBe(input.stopPropagation ?? true);
+ expect(result.match?.preventDefault).toBe(input.preventDefault ?? true);
+ expect(result.match?.stopPropagation).toBe(input.stopPropagation ?? true);
+ expect(result.preventDefault).toBe(false);
+ expect(result.nextChordState).toEqual(initialChordState());
}
function expectNoShortcutResolution(input: {
event: Partial;
context?: Partial;
}) {
- const match = resolveKeyboardShortcut({
- event: keyboardEvent(input.event),
- context: shortcutContext(input.context),
+ const result = resolveShortcut({
+ event: input.event,
+ context: input.context,
});
- expect(match).toBeNull();
+ expect(result.match).toBeNull();
+ expect(result.preventDefault).toBe(false);
+ expect(result.nextChordState).toEqual(initialChordState());
}
type MatchingShortcutCase = {
@@ -338,6 +369,66 @@ describe("keyboard-shortcuts", () => {
it.each(nonMatchingCases)("$name", ({ event, context }) => {
expectNoShortcutResolution({ event, context });
});
+
+ it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
+ const bindings = buildEffectiveBindings({
+ "workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
+ });
+ const chordBindingIndex = bindings.findIndex(
+ (binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac",
+ );
+ expect(chordBindingIndex).toBeGreaterThan(-1);
+
+ const firstResult = resolveShortcut({
+ event: { key: "w", code: "KeyW", ctrlKey: true },
+ context: { isMac: false, isDesktop: true },
+ bindings,
+ });
+
+ expect(firstResult.match).toBeNull();
+ expect(firstResult.preventDefault).toBe(true);
+ expect(firstResult.nextChordState.step).toBe(1);
+ expect(firstResult.nextChordState.candidateIndices).toEqual([chordBindingIndex]);
+
+ const secondResult = resolveShortcut({
+ event: { key: "s", code: "KeyS" },
+ context: { isMac: false, isDesktop: true },
+ chordState: firstResult.nextChordState,
+ bindings,
+ });
+
+ expect(secondResult.match?.action).toBe("workspace.terminal.new");
+ expect(secondResult.match?.payload).toBeNull();
+ expect(secondResult.match?.preventDefault).toBe(true);
+ expect(secondResult.match?.stopPropagation).toBe(true);
+ expect(secondResult.preventDefault).toBe(false);
+ expect(secondResult.nextChordState).toEqual(initialChordState());
+ });
+
+ it("schedules a chord reset timeout for advancing candidates", () => {
+ vi.useFakeTimers();
+
+ const bindings = buildEffectiveBindings({
+ "workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
+ });
+ const onChordReset = vi.fn();
+
+ const result = resolveShortcut({
+ event: { key: "w", code: "KeyW", ctrlKey: true },
+ context: { isMac: false, isDesktop: true },
+ onChordReset,
+ bindings,
+ });
+
+ expect(result.match).toBeNull();
+ expect(result.preventDefault).toBe(true);
+ expect(result.nextChordState.timeoutId).not.toBeNull();
+
+ vi.advanceTimersByTime(1500);
+
+ expect(onChordReset).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
});
describe("keyboard-shortcut help sections", () => {
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts
index b016f6bd2..8a46d8189 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.ts
@@ -5,7 +5,7 @@ import type {
KeyboardShortcutPayload,
MessageInputKeyboardActionKind,
} from "@/keyboard/actions";
-import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string";
+import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
export type { KeyCombo } from "@/keyboard/shortcut-string";
@@ -88,7 +88,13 @@ interface ShortcutBinding {
}
export interface ParsedShortcutBinding extends ShortcutBinding {
- parsedCombo: KeyCombo;
+ parsedChord: KeyCombo[];
+}
+
+export interface ChordState {
+ candidateIndices: number[];
+ step: number;
+ timeoutId: ReturnType | null;
}
// --- Constants ---
@@ -817,11 +823,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
// --- Parse bindings at module load ---
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
- const parsedCombo = parseShortcutString(binding.combo);
- if (binding.repeat === false) {
- parsedCombo.repeat = false;
+ const parsedChord = parseChordString(binding.combo);
+ const lastCombo = parsedChord.at(-1);
+ if (binding.repeat === false && lastCombo) {
+ lastCombo.repeat = false;
}
- return { ...binding, parsedCombo };
+ return { ...binding, parsedChord };
}
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
@@ -835,16 +842,17 @@ export function buildEffectiveBindings(
if (override === undefined) {
return binding;
}
- let parsedCombo: KeyCombo;
+ let parsedChord: KeyCombo[];
try {
- parsedCombo = parseShortcutString(override);
+ parsedChord = parseChordString(override);
} catch {
return binding;
}
- if (binding.repeat === false) {
- parsedCombo.repeat = false;
+ const lastCombo = parsedChord.at(-1);
+ if (binding.repeat === false && lastCombo) {
+ lastCombo.repeat = false;
}
- return { ...binding, combo: override, parsedCombo };
+ return { ...binding, combo: override, parsedChord };
});
}
@@ -921,6 +929,27 @@ function resolvePayload(
}
}
+const CHORD_TIMEOUT_MS = 1500;
+
+function clearChordTimeout(timeoutId: ReturnType | null): void {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ }
+}
+
+function createChordTimeout(onChordReset: () => void): ReturnType {
+ return setTimeout(onChordReset, CHORD_TIMEOUT_MS);
+}
+
+function resetChordState(input: ChordState): ChordState {
+ clearChordTimeout(input.timeoutId);
+ return {
+ candidateIndices: [],
+ step: 0,
+ timeoutId: null,
+ };
+}
+
function helpMatchesPlatform(
when: ShortcutWhen | undefined,
context: KeyboardShortcutPlatformContext,
@@ -935,24 +964,131 @@ function helpMatchesPlatform(
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
+ chordState: ChordState;
+ onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[];
-}): KeyboardShortcutMatch | null {
- const { event, context, bindings = DEFAULT_BINDINGS } = input;
- for (const binding of bindings) {
- if (!matchesCombo(binding.parsedCombo, event, context.isMac)) {
+}): {
+ match: KeyboardShortcutMatch | null;
+ nextChordState: ChordState;
+ preventDefault: boolean;
+};
+export function resolveKeyboardShortcut(input: {
+ event: KeyboardEvent;
+ context: KeyboardShortcutContext;
+ chordState: ChordState;
+ onChordReset: () => void;
+ bindings?: readonly ParsedShortcutBinding[];
+}): {
+ match: KeyboardShortcutMatch | null;
+ nextChordState: ChordState;
+ preventDefault: boolean;
+} {
+ const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
+
+ if (chordState.step === 0) {
+ const advancingCandidateIndices: number[] = [];
+ let singleComboMatch: KeyboardShortcutMatch | null = null;
+
+ for (const [index, binding] of bindings.entries()) {
+ const firstCombo = binding.parsedChord[0];
+ if (!firstCombo) {
+ continue;
+ }
+ if (!matchesCombo(firstCombo, event, context.isMac)) {
+ continue;
+ }
+ if (!matchesWhen(binding.when, context)) {
+ continue;
+ }
+ if (binding.parsedChord.length > 1) {
+ advancingCandidateIndices.push(index);
+ continue;
+ }
+ if (!singleComboMatch) {
+ singleComboMatch = {
+ action: binding.action,
+ payload: resolvePayload(binding.payload, event),
+ preventDefault: binding.preventDefault ?? true,
+ stopPropagation: binding.stopPropagation ?? true,
+ };
+ }
+ }
+
+ if (advancingCandidateIndices.length > 0) {
+ return {
+ match: null,
+ nextChordState: {
+ candidateIndices: advancingCandidateIndices,
+ step: 1,
+ timeoutId: createChordTimeout(onChordReset),
+ },
+ preventDefault: true,
+ };
+ }
+
+ return {
+ match: singleComboMatch,
+ nextChordState: resetChordState(chordState),
+ preventDefault: false,
+ };
+ }
+
+ const matchingCandidateIndices: number[] = [];
+ let completedMatch: KeyboardShortcutMatch | null = null;
+
+ for (const index of chordState.candidateIndices) {
+ const binding = bindings[index];
+ if (!binding) {
+ continue;
+ }
+ const combo = binding.parsedChord[chordState.step];
+ if (!combo) {
+ continue;
+ }
+ if (!matchesCombo(combo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
continue;
}
+ if (chordState.step + 1 === binding.parsedChord.length) {
+ completedMatch = {
+ action: binding.action,
+ payload: resolvePayload(binding.payload, event),
+ preventDefault: binding.preventDefault ?? true,
+ stopPropagation: binding.stopPropagation ?? true,
+ };
+ break;
+ }
+ matchingCandidateIndices.push(index);
+ }
+
+ if (completedMatch) {
return {
- action: binding.action,
- payload: resolvePayload(binding.payload, event),
- preventDefault: binding.preventDefault ?? true,
- stopPropagation: binding.stopPropagation ?? true,
+ match: completedMatch,
+ nextChordState: resetChordState(chordState),
+ preventDefault: false,
};
}
- return null;
+
+ if (matchingCandidateIndices.length > 0) {
+ clearChordTimeout(chordState.timeoutId);
+ return {
+ match: null,
+ nextChordState: {
+ candidateIndices: matchingCandidateIndices,
+ step: chordState.step + 1,
+ timeoutId: createChordTimeout(onChordReset),
+ },
+ preventDefault: true,
+ };
+ }
+
+ return {
+ match: null,
+ nextChordState: resetChordState(chordState),
+ preventDefault: false,
+ };
}
export function getBindingIdForAction(
diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts
index f55bcb637..2fc3e2e4a 100644
--- a/packages/app/src/keyboard/shortcut-string.ts
+++ b/packages/app/src/keyboard/shortcut-string.ts
@@ -103,6 +103,10 @@ export function parseShortcutString(s: string): KeyCombo {
return combo;
}
+export function parseChordString(s: string): KeyCombo[] {
+ return s.split(" ").map(parseShortcutString);
+}
+
export function keyComboToString(combo: KeyCombo): string {
const parts: string[] = [];
@@ -122,6 +126,10 @@ export function keyComboToString(combo: KeyCombo): string {
return parts.join("+");
}
+export function chordToString(chord: KeyCombo[]): string {
+ return chord.map(keyComboToString).join(" ");
+}
+
const MODIFIER_CODES = new Set([
"MetaLeft",
"MetaRight",
@@ -158,6 +166,10 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
return keys;
}
+export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
+ return s.split(" ").map(comboStringToShortcutKeys);
+}
+
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null;
diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx
index 7b1d91759..5d4ca56f4 100644
--- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx
+++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx
@@ -10,16 +10,31 @@ import {
getBindingIdForAction,
type KeyboardShortcutHelpRow,
} from "@/keyboard/keyboard-shortcuts";
-import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string";
+import {
+ chordStringToShortcutKeys,
+ comboStringToShortcutKeys,
+ keyboardEventToComboString,
+} from "@/keyboard/shortcut-string";
+import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
+function ShortcutSequence({ chord }: { chord: string[] | null }) {
+ if (!chord || chord.length === 0) {
+ return Press shortcut...;
+ }
+
+ return ;
+}
+
function ShortcutRow({
row,
bindingId,
overrideCombo,
isCapturing,
+ capturedCombos,
onRebind,
+ onDone,
onCancel,
onReset,
}: {
@@ -27,29 +42,38 @@ function ShortcutRow({
bindingId: string | null;
overrideCombo: string | undefined;
isCapturing: boolean;
+ capturedCombos: string[];
onRebind: () => void;
+ onDone: () => void;
onCancel: () => void;
onReset: () => void;
}) {
- const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys;
+ const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys];
return (
{row.label}
{isCapturing ? (
- Press shortcut...
+
) : (
-
+
)}
{bindingId !== null && (
-
+ <>
+ {isCapturing && capturedCombos.length > 0 ? (
+
+ ) : null}
+
+ >
)}
{overrideCombo !== undefined && !isCapturing && (