Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 90 additions & 31 deletions src/renderer/components/Settings/tabs/ShortcutsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
*/

import React, { useState, useRef, useEffect } from 'react';
import { Search } from 'lucide-react';
import { useSettings } from '../../../hooks';
import { formatShortcutKeys } from '../../../utils/shortcutFormatter';
import { buildKeysFromEvent } from '../../../utils/shortcutRecorder';
import type { Theme, Shortcut } from '../../../types';

export interface ShortcutsTabProps {
Expand All @@ -22,12 +24,15 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut

const [recordingId, setRecordingId] = useState<string | null>(null);
const [shortcutsFilter, setShortcutsFilter] = useState('');
const [recordingFilterShortcut, setRecordingFilterShortcut] = useState(false);
const [filterShortcutKeys, setFilterShortcutKeys] = useState<string[]>([]);
const shortcutsFilterRef = useRef<HTMLInputElement>(null);
const filterShortcutButtonRef = useRef<HTMLButtonElement>(null);

// Notify parent of recording state changes (for escape handler coordination)
useEffect(() => {
onRecordingChange?.(!!recordingId);
}, [recordingId, onRecordingChange]);
onRecordingChange?.(!!recordingId || recordingFilterShortcut);
}, [recordingId, recordingFilterShortcut, onRecordingChange]);

// Auto-focus filter input on mount
useEffect(() => {
Expand All @@ -49,28 +54,8 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut
return;
}

const keys = [];
if (e.metaKey) keys.push('Meta');
if (e.ctrlKey) keys.push('Ctrl');
if (e.altKey) keys.push('Alt');
if (e.shiftKey) keys.push('Shift');
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;

// On macOS, Alt+letter produces special characters (e.g., Alt+L = ¬, Alt+P = π)
// Use e.code to get the physical key name when Alt is pressed
let mainKey = e.key;
if (e.altKey && e.code) {
// e.code is like 'KeyL', 'KeyP', 'Digit1', etc.
if (e.code.startsWith('Key')) {
mainKey = e.code.replace('Key', '').toLowerCase();
} else if (e.code.startsWith('Digit')) {
mainKey = e.code.replace('Digit', '');
} else {
// For other keys like Arrow keys, use as-is
mainKey = e.key;
}
}
keys.push(mainKey);
const keys = buildKeysFromEvent(e);
if (!keys) return;

if (isTabShortcut) {
setTabShortcuts({
Expand All @@ -86,14 +71,36 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut
setRecordingId(null);
};

const handleFilterRecord = (e: React.KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();

if (e.key === 'Escape') {
setRecordingFilterShortcut(false);
setFilterShortcutKeys([]);
return;
}

const keys = buildKeysFromEvent(e);
if (!keys) return;

setFilterShortcutKeys(keys);
setRecordingFilterShortcut(false);
};
Comment thread
scriptease marked this conversation as resolved.

const allShortcuts = [
...Object.values(shortcuts).map((sc) => ({ ...sc, isTabShortcut: false })),
...Object.values(tabShortcuts).map((sc) => ({ ...sc, isTabShortcut: true })),
];
const totalShortcuts = allShortcuts.length;
const filteredShortcuts = allShortcuts.filter((sc) =>
sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase())
);
const filteredShortcuts = allShortcuts.filter((sc) => {
if (filterShortcutKeys.length > 0) {
const sortedFilter = [...filterShortcutKeys].sort().join('+');
const sortedKeys = [...sc.keys].sort().join('+');
return sortedKeys === sortedFilter;
}
return sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase());
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const filteredCount = filteredShortcuts.length;

// Group shortcuts by category
Expand Down Expand Up @@ -150,24 +157,76 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut
Note: Most functionality is unavailable until you've created your first agent.
</p>
)}
<div className="flex items-center gap-2 mb-3">
<div className="flex items-stretch gap-2 mb-3">
<input
ref={shortcutsFilterRef}
type="text"
value={shortcutsFilter}
onChange={(e) => setShortcutsFilter(e.target.value)}
onChange={(e) => {
setShortcutsFilter(e.target.value);
setFilterShortcutKeys([]);
}}
Comment thread
scriptease marked this conversation as resolved.
placeholder="Filter shortcuts..."
className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
<button
ref={filterShortcutButtonRef}
onClick={() => {
if (filterShortcutKeys.length > 0) {
setFilterShortcutKeys([]);
setRecordingFilterShortcut(false);
} else {
setRecordingFilterShortcut(true);
filterShortcutButtonRef.current?.focus();
}
}}
onKeyDownCapture={(e) => {
if (recordingFilterShortcut) {
handleFilterRecord(e);
}
}}
onBlur={() => setRecordingFilterShortcut(false)}
className={`px-3 py-2 rounded border text-xs font-mono whitespace-nowrap text-center transition-colors ${recordingFilterShortcut ? 'ring-2' : ''}`}
style={
{
borderColor:
recordingFilterShortcut || filterShortcutKeys.length > 0
? theme.colors.accent
: theme.colors.border,
backgroundColor:
recordingFilterShortcut || filterShortcutKeys.length > 0
? theme.colors.accentDim
: theme.colors.bgActivity,
color:
recordingFilterShortcut || filterShortcutKeys.length > 0
? theme.colors.accent
: theme.colors.textDim,
'--tw-ring-color': theme.colors.accent,
} as React.CSSProperties
}
>
{recordingFilterShortcut ? (
'Press keys...'
) : filterShortcutKeys.length > 0 ? (
formatShortcutKeys(filterShortcutKeys)
) : (
<span className="flex items-center gap-1">
<Search className="w-3 h-3" />
By Key
</span>
)}
</button>
<span
className="text-xs px-2 py-1.5 rounded font-medium"
className="text-xs px-2 rounded font-medium flex items-center"
style={{
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textDim,
}}
>
{shortcutsFilter ? `${filteredCount} / ${totalShortcuts}` : totalShortcuts}
{shortcutsFilter || filterShortcutKeys.length > 0
? `${filteredCount} / ${totalShortcuts}`
: totalShortcuts}
</span>
</div>
<p className="text-xs opacity-50 mb-3" style={{ color: theme.colors.textDim }}>
Expand Down
128 changes: 112 additions & 16 deletions src/renderer/components/ShortcutsHelpModal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useState, useRef, useMemo } from 'react';
import { X, Award, CheckCircle, Trophy, ExternalLink } from 'lucide-react';
import React, { useState, useRef, useMemo, useCallback } from 'react';
import { X, Award, CheckCircle, Trophy, ExternalLink, Search } from 'lucide-react';
import { GhostIconButton } from './ui/GhostIconButton';
import type { Theme, Shortcut, KeyboardMasteryStats } from '../types';
import { fuzzyMatch } from '../utils/search';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { FIXED_SHORTCUTS } from '../constants/shortcuts';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
import { buildKeysFromEvent } from '../utils/shortcutRecorder';
import { Modal } from './ui/Modal';
import { KEYBOARD_MASTERY_LEVELS, getLevelForPercentage } from '../constants/keyboardMastery';
import { openUrl } from '../utils/openUrl';
Expand All @@ -29,7 +30,23 @@ export function ShortcutsHelpModal({
keyboardMasteryStats,
}: ShortcutsHelpModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [recordingFilterShortcut, setRecordingFilterShortcut] = useState(false);
const [filterShortcutKeys, setFilterShortcutKeys] = useState<string[]>([]);
const searchInputRef = useRef<HTMLInputElement>(null);
const filterShortcutButtonRef = useRef<HTMLButtonElement>(null);
// Ref mirrors recording state so onBeforeClose stays stable for layer registration.
const recordingRef = useRef(recordingFilterShortcut);
recordingRef.current = recordingFilterShortcut;

// Block modal close on Escape while recording — instead, cancel the recording.
const handleBeforeClose = useCallback(() => {
if (recordingRef.current) {
setRecordingFilterShortcut(false);
setFilterShortcutKeys([]);
return false;
}
return true;
}, []);

// Combine all shortcuts for display and mastery tracking
const allShortcuts = useMemo(
Expand All @@ -54,8 +71,32 @@ export function ShortcutsHelpModal({
return KEYBOARD_MASTERY_LEVELS.find((l) => l.threshold > masteryPercentage);
}, [masteryPercentage]);
const usedShortcutIds = new Set(keyboardMasteryStats?.usedShortcuts ?? []);
const handleFilterRecord = (e: React.KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();

if (e.key === 'Escape') {
setRecordingFilterShortcut(false);
setFilterShortcutKeys([]);
return;
}

const keys = buildKeysFromEvent(e);
if (!keys) return;

setFilterShortcutKeys(keys);
setRecordingFilterShortcut(false);
};

const filteredShortcuts = Object.values(allShortcuts)
.filter((sc) => fuzzyMatch(sc.label, searchQuery) || fuzzyMatch(sc.keys.join(' '), searchQuery))
.filter((sc) => {
if (filterShortcutKeys.length > 0) {
const sortedFilter = [...filterShortcutKeys].sort().join('+');
const sortedKeys = [...sc.keys].sort().join('+');
return sortedKeys === sortedFilter;
}
return fuzzyMatch(sc.label, searchQuery) || fuzzyMatch(sc.keys.join(' '), searchQuery);
})
.sort((a, b) => a.label.localeCompare(b.label));
const filteredCount = filteredShortcuts.length;

Expand All @@ -68,10 +109,12 @@ export function ShortcutsHelpModal({
Keyboard Shortcuts
</h2>
<span
className="text-xs px-2 py-0.5 rounded"
className="text-xs px-2 py-2 rounded"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
>
{searchQuery ? `${filteredCount} / ${totalShortcuts}` : totalShortcuts}
{searchQuery || filterShortcutKeys.length > 0
? `${filteredCount} / ${totalShortcuts}`
: totalShortcuts}
</span>
</div>
<GhostIconButton onClick={onClose} color={theme.colors.textDim} ariaLabel="Close">
Expand All @@ -87,15 +130,67 @@ export function ShortcutsHelpModal({
Note: Most functionality is unavailable until you've created your first agent.
</p>
)}
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search shortcuts..."
className="w-full px-3 py-2 rounded border bg-transparent outline-none text-sm"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
<div className="flex items-stretch gap-2">
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setFilterShortcutKeys([]);
}}
Comment thread
scriptease marked this conversation as resolved.
placeholder="Search shortcuts..."
className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm"
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
/>
<button
ref={filterShortcutButtonRef}
onClick={() => {
if (filterShortcutKeys.length > 0) {
setFilterShortcutKeys([]);
setRecordingFilterShortcut(false);
} else {
setRecordingFilterShortcut(true);
filterShortcutButtonRef.current?.focus();
}
}}
onKeyDownCapture={(e) => {
if (recordingFilterShortcut) {
handleFilterRecord(e);
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onBlur={() => setRecordingFilterShortcut(false)}
className={`px-3 py-2 rounded border text-xs font-mono whitespace-nowrap text-center transition-colors ${recordingFilterShortcut ? 'ring-2' : ''}`}
style={
{
borderColor:
recordingFilterShortcut || filterShortcutKeys.length > 0
? theme.colors.accent
: theme.colors.border,
backgroundColor:
recordingFilterShortcut || filterShortcutKeys.length > 0
? theme.colors.accentDim
: theme.colors.bgActivity,
color:
recordingFilterShortcut || filterShortcutKeys.length > 0
? theme.colors.accent
: theme.colors.textDim,
'--tw-ring-color': theme.colors.accent,
} as React.CSSProperties
}
>
{recordingFilterShortcut ? (
'Press keys...'
) : filterShortcutKeys.length > 0 ? (
formatShortcutKeys(filterShortcutKeys)
) : (
<span className="flex items-center gap-1">
<Search className="w-3 h-3" />
By Key
</span>
)}
</button>
</div>
<p className="text-xs mt-2" style={{ color: theme.colors.textDim }}>
Many shortcuts can be customized from Settings → Shortcuts.
</p>
Expand Down Expand Up @@ -165,12 +260,13 @@ export function ShortcutsHelpModal({
customHeader={customHeader}
footer={footer}
initialFocusRef={searchInputRef}
layerOptions={{ onBeforeClose: handleBeforeClose }}
>
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin -my-2">
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-track-transparent -mr-6 pr-6 -my-2">
{filteredShortcuts.map((sc, i) => {
const isUsed = usedShortcutIds.has(sc.id);
return (
<div key={i} className="flex justify-between items-center text-sm gap-2">
<div key={i} className="flex justify-between items-center text-sm gap-4">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{keyboardMasteryStats && (
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
Expand Down
9 changes: 0 additions & 9 deletions src/renderer/constants/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,6 @@ export const FIXED_SHORTCUTS: Record<string, Shortcut> = {
},
};

// Terminal tab shortcuts
export const TERMINAL_SHORTCUTS: Record<string, Shortcut> = {
newTerminalTab: {
id: 'newTerminalTab',
label: 'New Terminal Tab',
keys: ['Control', 'Shift', '`'],
},
};

// Tab navigation shortcuts (AI mode only)
export const TAB_SHORTCUTS: Record<string, Shortcut> = {
tabSwitcher: { id: 'tabSwitcher', label: 'Tab Switcher', keys: ['Alt', 'Meta', 't'] },
Expand Down
Loading