Add chord keyboard shortcuts and staged capture UI

This commit is contained in:
Mohamed Boudra
2026-03-22 16:28:09 +07:00
parent d9bcf2a439
commit 57f8c7615c
16 changed files with 498 additions and 99 deletions
@@ -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 <Text style={styles.capturingText}>Press shortcut...</Text>;
}
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
}
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 (
<View style={[styles.row, isCapturing && styles.rowCapturing]}>
<Text style={styles.rowLabel}>{row.label}</Text>
<View style={styles.rowActions}>
{isCapturing ? (
<Text style={styles.capturingText}>Press shortcut...</Text>
<ShortcutSequence chord={capturedCombos} />
) : (
<Shortcut keys={displayKeys} />
<Shortcut chord={displayChord} />
)}
{bindingId !== null && (
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
<>
{isCapturing && capturedCombos.length > 0 ? (
<Button variant="ghost" size="sm" onPress={onDone}>
Done
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
</>
)}
{overrideCombo !== undefined && !isCapturing && (
<Button variant="ghost" size="sm" onPress={onReset}>
@@ -63,46 +87,73 @@ function ShortcutRow({
export function KeyboardShortcutsSection() {
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
useKeyboardShortcutOverrides();
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
function cancelCapture() {
setCapturedCombos([]);
setCapturingBindingId(null);
setCapturingShortcut(false);
}
function startCapture(bindingId: string) {
setCapturedCombos([]);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
}
function saveCapture() {
if (capturingBindingId === null || capturedCombos.length === 0) {
return;
}
void setOverride(capturingBindingId, capturedCombos.join(" "));
cancelCapture();
}
useEffect(() => {
if (Platform.OS !== "web") return;
if (capturingBindingId === null) return;
const activeBindingId = capturingBindingId;
function handleKeyDown(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
const key = event.key ?? "";
if (key === "Escape") {
event.preventDefault();
event.stopPropagation();
setCapturingBindingId(null);
cancelCapture();
return;
}
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
if (key === "Backspace") {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
return;
}
const comboString = keyboardEventToComboString(event);
if (comboString === null) return;
if (comboString === null) {
return;
}
event.preventDefault();
event.stopPropagation();
void setOverride(activeBindingId, comboString);
setCapturingBindingId(null);
setCapturedCombos((current) => [...current, comboString]);
}
window.addEventListener("keydown", handleKeyDown, true);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
};
}, [capturingBindingId, setOverride]);
}, [cancelCapture, capturingBindingId]);
useEffect(() => {
return () => {
setCapturingShortcut(false);
};
}, [setCapturingShortcut]);
if (Platform.OS !== "web") {
return (
@@ -144,8 +195,14 @@ export function KeyboardShortcutsSection() {
bindingId={bindingId}
overrideCombo={overrideCombo}
isCapturing={capturingBindingId === bindingId}
onRebind={() => setCapturingBindingId(bindingId)}
onCancel={() => setCapturingBindingId(null)}
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
onRebind={() => {
if (bindingId) {
startCapture(bindingId);
}
}}
onDone={saveCapture}
onCancel={cancelCapture}
onReset={() => {
if (bindingId) void removeOverride(bindingId);
}}
@@ -457,7 +457,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
{newAgentTabKeys ? (
<Shortcut keys={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
@@ -478,7 +478,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
{newTerminalTabKeys ? (
<Shortcut keys={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
<Shortcut chord={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
@@ -499,7 +499,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? (
<Shortcut keys={splitRightKeys} style={styles.newTabTooltipShortcut} />
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>
@@ -520,7 +520,7 @@ export function WorkspaceDesktopTabsRow({
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? (
<Shortcut keys={splitDownKeys} style={styles.newTabTooltipShortcut} />
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
</TooltipContent>