Add customizable keyboard shortcuts and new panel/service bindings (#457)
* feat(shortcuts): centralize shortcut registry and matching * feat(ui-store): persist customizable shortcut overrides * feat(shortcuts): wire effective shortcuts into global handlers * feat(help): render shortcut hints from effective mappings * feat(command-palette): show and trigger panel shortcut actions * feat(settings): add keyboard shortcuts customization section * feat(settings): add shortcuts section to OpenChamber sidebar * feat(settings): render keyboard shortcuts section content * feat(header): add shortcut-driven services and plan controls * fix(shortcuts): support unassigned overrides and bracket key matching * fix(settings): clear overwritten shortcut conflicts safely * fix: shortcut conflicts, focus retention, and theme-safe warning text --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
47cecfc356
commit
d9370f3af5
@@ -36,6 +36,7 @@ import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calcu
|
||||
import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
||||
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import {
|
||||
getAllModelFamilies,
|
||||
getDisplayModelName,
|
||||
@@ -148,6 +149,7 @@ export const Header: React.FC = () => {
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
|
||||
const { getCurrentModel } = useConfigStore();
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
@@ -1064,6 +1066,10 @@ export const Header: React.FC = () => {
|
||||
return base;
|
||||
}, [diffFileCount, isMobile, showPlanTab]);
|
||||
|
||||
const shortcutLabel = React.useCallback((actionId: string) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||
}, [shortcutOverrides]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files')) {
|
||||
setActiveMainTab('chat');
|
||||
@@ -1114,6 +1120,65 @@ export const Header: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [tabs, setActiveMainTab, showProjectTabs, projects, activeProjectId, setActiveProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, toggleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isDesktopServicesOpen) {
|
||||
setIsDesktopServicesOpen(false);
|
||||
} else {
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
if (desktopServicesTab === 'usage' && quotaResults.length === 0) {
|
||||
void fetchAllQuotas();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, cycleServicesCombo)) {
|
||||
e.preventDefault();
|
||||
|
||||
const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>;
|
||||
if (tabValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = tabValues.indexOf(desktopServicesTab);
|
||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length;
|
||||
const nextTab = tabValues[nextIndex];
|
||||
setDesktopServicesTab(nextTab);
|
||||
setIsDesktopServicesOpen(true);
|
||||
void refreshCurrentInstanceLabel();
|
||||
if (nextTab === 'usage' && quotaResults.length === 0) {
|
||||
void fetchAllQuotas();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides);
|
||||
if (eventMatchesShortcut(e, toggleContextPlanCombo)) {
|
||||
e.preventDefault();
|
||||
handleOpenContextPlan();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [
|
||||
shortcutOverrides,
|
||||
isDesktopServicesOpen,
|
||||
desktopServicesTab,
|
||||
servicesTabs,
|
||||
quotaResults.length,
|
||||
fetchAllQuotas,
|
||||
refreshCurrentInstanceLabel,
|
||||
handleOpenContextPlan,
|
||||
]);
|
||||
|
||||
const renderTab = (tab: TabConfig) => {
|
||||
const isActive = activeMainTab === tab.id;
|
||||
const isDiffTab = tab.icon === 'diff';
|
||||
@@ -1453,14 +1518,21 @@ export const Header: React.FC = () => {
|
||||
role="tablist"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSessionSwitcher}
|
||||
aria-label="Open sessions"
|
||||
className={`${headerIconButtonClass} mr-2 shrink-0`}
|
||||
>
|
||||
<RiLayoutLeftLine className="h-5 w-5" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSessionSwitcher}
|
||||
aria-label="Open sessions"
|
||||
className={`${headerIconButtonClass} mr-2 shrink-0`}
|
||||
>
|
||||
<RiLayoutLeftLine className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open sessions ({shortcutLabel('toggle_sidebar')})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Project tabs */}
|
||||
{showProjectTabs && (
|
||||
@@ -1683,7 +1755,7 @@ export const Header: React.FC = () => {
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Plan</p>
|
||||
<p>Plan ({shortcutLabel('toggle_context_plan')})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -1721,7 +1793,9 @@ export const Header: React.FC = () => {
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'}</p>
|
||||
<p>
|
||||
{isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
@@ -1954,7 +2028,7 @@ export const Header: React.FC = () => {
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal panel</p>
|
||||
<p>Terminal panel ({shortcutLabel('toggle_terminal')})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1970,7 +2044,7 @@ export const Header: React.FC = () => {
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Right sidebar</p>
|
||||
<p>Right sidebar ({shortcutLabel('toggle_right_sidebar')})</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -2407,7 +2481,7 @@ export const Header: React.FC = () => {
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{updateAvailable ? 'Settings (Update available)' : 'Settings'}</p>
|
||||
<p>{updateAvailable ? `Settings (Update available) (${shortcutLabel('open_settings')})` : `Settings (${shortcutLabel('open_settings')})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getCustomizableShortcutActions,
|
||||
getEffectiveShortcutCombo,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
normalizeCombo,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutCombo,
|
||||
} from '@/lib/shortcuts';
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||
|
||||
const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
|
||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
parts.push('mod');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
parts.push('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
parts.push('alt');
|
||||
}
|
||||
|
||||
const keyToken = keyToShortcutToken(event.key);
|
||||
if (!keyToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parts.push(keyToken);
|
||||
return normalizeCombo(parts.join('+'));
|
||||
};
|
||||
|
||||
export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
const {
|
||||
shortcutOverrides,
|
||||
setShortcutOverride,
|
||||
clearShortcutOverride,
|
||||
resetAllShortcutOverrides,
|
||||
} = useUIStore();
|
||||
|
||||
const actions = React.useMemo(() => getCustomizableShortcutActions(), []);
|
||||
|
||||
const [capturingActionId, setCapturingActionId] = React.useState<string | null>(null);
|
||||
const [draftByAction, setDraftByAction] = React.useState<Record<string, ShortcutCombo>>({});
|
||||
const [errorText, setErrorText] = React.useState<string>('');
|
||||
const [warningText, setWarningText] = React.useState<string>('');
|
||||
const [pendingOverwrite, setPendingOverwrite] = React.useState<{
|
||||
actionId: string;
|
||||
combo: ShortcutCombo;
|
||||
conflictActionId: string;
|
||||
} | null>(null);
|
||||
|
||||
const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => {
|
||||
const normalized = normalizeCombo(combo);
|
||||
for (const action of actions) {
|
||||
if (action.id === actionId) {
|
||||
continue;
|
||||
}
|
||||
const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
if (normalizeCombo(existing) === normalized) {
|
||||
return action.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [actions, shortcutOverrides]);
|
||||
|
||||
const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => {
|
||||
const normalized = normalizeCombo(combo);
|
||||
const conflictActionId = findConflict(actionId, normalized);
|
||||
if (conflictActionId) {
|
||||
setPendingOverwrite({ actionId, combo: normalized, conflictActionId });
|
||||
setErrorText('');
|
||||
return;
|
||||
}
|
||||
|
||||
setShortcutOverride(actionId, normalized);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(normalized) ? 'This shortcut can conflict with browser defaults. It is still saved.' : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [findConflict, setShortcutOverride]);
|
||||
|
||||
const confirmOverwrite = React.useCallback(() => {
|
||||
if (!pendingOverwrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT);
|
||||
setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? 'This shortcut can conflict with browser defaults. It is still saved.' : '');
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[pendingOverwrite.actionId];
|
||||
return rest;
|
||||
});
|
||||
}, [pendingOverwrite, setShortcutOverride]);
|
||||
|
||||
const resetOne = React.useCallback((actionId: string) => {
|
||||
clearShortcutOverride(actionId);
|
||||
setDraftByAction((current) => {
|
||||
const rest = { ...current };
|
||||
delete rest[actionId];
|
||||
return rest;
|
||||
});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}, [clearShortcutOverride]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Keyboard Shortcuts</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Capture a new key combo, save it, and the runtime/help/palette bindings update together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{actions.map((action) => {
|
||||
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||
const draft = draftByAction[action.id];
|
||||
const displayCombo = draft ?? effective;
|
||||
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
|
||||
|
||||
return (
|
||||
<div key={action.id} className="rounded-md border border-border/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="typography-ui-label text-foreground">{action.label}</p>
|
||||
{action.description && (
|
||||
<p className="typography-meta text-muted-foreground">{action.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={capturingActionId === action.id ? 'Press keys...' : formatShortcutForDisplay(displayCombo)}
|
||||
onFocus={() => {
|
||||
setCapturingActionId(action.id);
|
||||
setErrorText('');
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (capturingActionId === action.id) {
|
||||
setCapturingActionId(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setCapturingActionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const combo = keyboardEventToCombo(event);
|
||||
if (!combo) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftByAction((current) => ({
|
||||
...current,
|
||||
[action.id]: combo,
|
||||
}));
|
||||
setCapturingActionId(null);
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
}}
|
||||
className="w-52"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const next = draftByAction[action.id];
|
||||
if (!next) {
|
||||
setErrorText('Capture a shortcut first.');
|
||||
return;
|
||||
}
|
||||
saveCombo(action.id, next);
|
||||
}}
|
||||
disabled={!hasDraft}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{pendingOverwrite && (
|
||||
<div className="rounded-md border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
|
||||
<p className="typography-meta" style={{ color: 'var(--surface-foreground)' }}>
|
||||
This combo is already used by another shortcut. Overwrite and clear that other mapping?
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button type="button" size="sm" onClick={confirmOverwrite}>Overwrite</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorText && (
|
||||
<div
|
||||
className="rounded-md border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-2 typography-meta"
|
||||
style={{ color: 'var(--surface-foreground)' }}
|
||||
>
|
||||
{errorText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warningText && (
|
||||
<div
|
||||
className="rounded-md border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-2 typography-meta"
|
||||
style={{ color: 'var(--surface-foreground)' }}
|
||||
>
|
||||
{warningText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetAllShortcutOverrides();
|
||||
setDraftByAction({});
|
||||
setPendingOverwrite(null);
|
||||
setErrorText('');
|
||||
setWarningText('');
|
||||
}}
|
||||
>
|
||||
Reset all shortcuts
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { NotificationSettings } from './NotificationSettings';
|
||||
import { GitHubSettings } from './GitHubSettings';
|
||||
import { VoiceSettings } from './VoiceSettings';
|
||||
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
||||
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
@@ -65,6 +66,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
return <ChatSectionContent />;
|
||||
case 'sessions':
|
||||
return <SessionsSectionContent />;
|
||||
case 'shortcuts':
|
||||
return <ShortcutsSectionContent />;
|
||||
case 'git':
|
||||
return <GitSectionContent />;
|
||||
case 'github':
|
||||
@@ -91,6 +94,10 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
);
|
||||
};
|
||||
|
||||
const ShortcutsSectionContent: React.FC = () => {
|
||||
return <KeyboardShortcutsSettings />;
|
||||
};
|
||||
|
||||
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile)
|
||||
const VisualSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AboutSettings } from './AboutSettings';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'git' | 'github' | 'notifications' | 'voice';
|
||||
export type OpenChamberSection = 'visual' | 'chat' | 'shortcuts' | 'sessions' | 'git' | 'github' | 'notifications' | 'voice';
|
||||
|
||||
interface OpenChamberSidebarProps {
|
||||
selectedSection: OpenChamberSection;
|
||||
@@ -35,6 +35,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
||||
label: 'Chat',
|
||||
items: ['Tools', 'Diff', 'Reasoning'],
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
label: 'Shortcuts',
|
||||
items: ['Keyboard', 'Overrides'],
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Sessions',
|
||||
|
||||
@@ -15,9 +15,9 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
||||
import { getModifierLabel } from '@/lib/utils';
|
||||
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const {
|
||||
@@ -29,6 +29,13 @@ export const CommandPalette: React.FC = () => {
|
||||
setSessionSwitcherOpen,
|
||||
setTimelineDialogOpen,
|
||||
toggleSidebar,
|
||||
toggleRightSidebar,
|
||||
setRightSidebarOpen,
|
||||
setRightSidebarTab,
|
||||
toggleBottomTerminal,
|
||||
setBottomTerminalExpanded,
|
||||
isBottomTerminalExpanded,
|
||||
shortcutOverrides,
|
||||
} = useUIStore();
|
||||
|
||||
const {
|
||||
@@ -105,6 +112,33 @@ export const CommandPalette: React.FC = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleToggleRightSidebar = () => {
|
||||
toggleRightSidebar();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleOpenRightSidebarGit = () => {
|
||||
setRightSidebarOpen(true);
|
||||
setRightSidebarTab('git');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleOpenRightSidebarFiles = () => {
|
||||
setRightSidebarOpen(true);
|
||||
setRightSidebarTab('files');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleToggleTerminalDock = () => {
|
||||
toggleBottomTerminal();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleToggleTerminalExpanded = () => {
|
||||
setBottomTerminalExpanded(!isBottomTerminalExpanded);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleOpenTimeline = () => {
|
||||
setTimelineDialogOpen(true);
|
||||
handleClose();
|
||||
@@ -115,6 +149,10 @@ export const CommandPalette: React.FC = () => {
|
||||
return directorySessions.slice(0, 5);
|
||||
}, [directorySessions]);
|
||||
|
||||
const shortcut = React.useCallback((actionId: string) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||
}, [shortcutOverrides]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={isCommandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
@@ -125,51 +163,76 @@ export const CommandPalette: React.FC = () => {
|
||||
<CommandItem onSelect={handleOpenSessionList}>
|
||||
<RiLayoutLeftLine className="mr-2 h-4 w-4" />
|
||||
<span>Open Session List</span>
|
||||
<CommandShortcut>{getModifierLabel()} + L</CommandShortcut>
|
||||
<CommandShortcut>{shortcut('toggle_sidebar')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleCreateSession}>
|
||||
<RiAddLine className="mr-2 h-4 w-4" />
|
||||
<span>New Session</span>
|
||||
<CommandShortcut>
|
||||
{settingsAutoCreateWorktree ? `Shift + ${getModifierLabel()} + N` : `${getModifierLabel()} + N`}
|
||||
{settingsAutoCreateWorktree ? shortcut('new_chat_worktree') : shortcut('new_chat')}
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleCreateWorktreeSession}>
|
||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||
<span>New Session with Worktree</span>
|
||||
<CommandShortcut>
|
||||
{settingsAutoCreateWorktree ? `${getModifierLabel()} + N` : `Shift + ${getModifierLabel()} + N`}
|
||||
{settingsAutoCreateWorktree ? shortcut('new_chat') : shortcut('new_chat_worktree')}
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleToggleRightSidebar}>
|
||||
<RiLayoutRightLine className="mr-2 h-4 w-4" />
|
||||
<span>Toggle Right Sidebar</span>
|
||||
<CommandShortcut>{shortcut('toggle_right_sidebar')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenRightSidebarGit}>
|
||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||
<span>Open Right Sidebar Git</span>
|
||||
<CommandShortcut>{shortcut('open_right_sidebar_git')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenRightSidebarFiles}>
|
||||
<RiLayoutRightLine className="mr-2 h-4 w-4" />
|
||||
<span>Open Right Sidebar Files</span>
|
||||
<CommandShortcut>{shortcut('open_right_sidebar_files')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleToggleTerminalDock}>
|
||||
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
|
||||
<span>Toggle Terminal Dock</span>
|
||||
<CommandShortcut>{shortcut('toggle_terminal')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleToggleTerminalExpanded}>
|
||||
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
|
||||
<span>Toggle Terminal Expanded</span>
|
||||
<CommandShortcut>{shortcut('toggle_terminal_expanded')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowHelp}>
|
||||
<RiQuestionLine className="mr-2 h-4 w-4" />
|
||||
<span>Keyboard Shortcuts</span>
|
||||
<CommandShortcut>{getModifierLabel()} + .</CommandShortcut>
|
||||
<CommandShortcut>{shortcut('open_help')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenDiffPanel}>
|
||||
<RiCodeLine className="mr-2 h-4 w-4" />
|
||||
<span>Open Diff Panel</span>
|
||||
<CommandShortcut>{getModifierLabel()} + 2</CommandShortcut>
|
||||
<CommandShortcut>{shortcut('open_diff_panel')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenTerminal}>
|
||||
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
|
||||
<span>Open Terminal</span>
|
||||
<CommandShortcut>{getModifierLabel()} + 3</CommandShortcut>
|
||||
<CommandShortcut>{shortcut('open_terminal_panel')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenGitPanel}>
|
||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||
<span>Open Git Panel</span>
|
||||
<CommandShortcut>{getModifierLabel()} + 4</CommandShortcut>
|
||||
<CommandShortcut>{shortcut('open_git_panel')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenTimeline}>
|
||||
<RiTimeLine className="mr-2 h-4 w-4" />
|
||||
<span>Open Timeline</span>
|
||||
<CommandShortcut>{getModifierLabel()} + T</CommandShortcut>
|
||||
<CommandShortcut>{shortcut('open_timeline')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<RiSettings3Line className="mr-2 h-4 w-4" />
|
||||
<span>Open Settings</span>
|
||||
<CommandShortcut>{getModifierLabel()} + ,</CommandShortcut>
|
||||
<CommandShortcut>{shortcut('open_settings')}</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
|
||||
@@ -12,66 +12,31 @@ import {
|
||||
RiAddLine,
|
||||
RiAiAgentLine,
|
||||
RiAiGenerate2,
|
||||
RiArrowUpSLine,
|
||||
RiBrainAi3Line,
|
||||
RiCloseCircleLine,
|
||||
RiCommandLine,
|
||||
RiGitBranchLine,
|
||||
RiLayoutLeftLine,
|
||||
RiLayoutRightLine,
|
||||
RiPaletteLine,
|
||||
RiQuestionLine,
|
||||
RiSettings3Line,
|
||||
RiStackLine,
|
||||
RiText,
|
||||
RiTimeLine,
|
||||
RiWindowLine,
|
||||
} from "@remixicon/react";
|
||||
import { getModifierLabel } from "@/lib/utils";
|
||||
|
||||
const renderKeyToken = (token: string, index: number) => {
|
||||
const normalized = token.trim().toLowerCase();
|
||||
|
||||
if (normalized === "ctrl" || normalized === "control") {
|
||||
return <RiArrowUpSLine key={`ctrl-${index}`} className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized === "⌘" ||
|
||||
normalized === "cmd" ||
|
||||
normalized === "command" ||
|
||||
normalized === "meta"
|
||||
) {
|
||||
return <RiCommandLine key={`cmd-${index}`} className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`key-${index}`} className="text-xs font-medium">
|
||||
{token.trim()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderKeyCombo = (combo: string) => {
|
||||
const tokens = combo
|
||||
.split("+")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
return combo.trim();
|
||||
}
|
||||
|
||||
return tokens.map((token, index) => (
|
||||
<React.Fragment key={`${token}-${index}`}>
|
||||
{index > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">+</span>
|
||||
)}
|
||||
{renderKeyToken(token, index)}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
import {
|
||||
getEffectiveShortcutCombo,
|
||||
getShortcutAction,
|
||||
getModifierLabel,
|
||||
formatShortcutForDisplay,
|
||||
} from "@/lib/shortcuts";
|
||||
|
||||
type ShortcutIcon = React.ComponentType<{ className?: string }>;
|
||||
|
||||
type ShortcutItem = {
|
||||
id?: string;
|
||||
keys: string | string[];
|
||||
description: string;
|
||||
icon: ShortcutIcon | null;
|
||||
@@ -82,10 +47,14 @@ type ShortcutSection = {
|
||||
items: ShortcutItem[];
|
||||
};
|
||||
|
||||
export const HelpDialog: React.FC = () => {
|
||||
const { isHelpDialogOpen, setHelpDialogOpen } = useUIStore();
|
||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||
const renderShortcut = (id: string, fallbackCombo: string, overrides: Record<string, string>) => {
|
||||
const action = getShortcutAction(id);
|
||||
return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo;
|
||||
};
|
||||
|
||||
export const HelpDialog: React.FC = () => {
|
||||
const { isHelpDialogOpen, setHelpDialogOpen, shortcutOverrides } = useUIStore();
|
||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||
const mod = getModifierLabel();
|
||||
|
||||
const shortcuts: ShortcutSection[] = [
|
||||
@@ -93,34 +62,39 @@ export const HelpDialog: React.FC = () => {
|
||||
category: "Navigation & Commands",
|
||||
items: [
|
||||
{
|
||||
keys: [`${mod} + K`],
|
||||
id: 'open_command_palette',
|
||||
description: "Open Command Palette",
|
||||
icon: RiCommandLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + .`],
|
||||
id: 'open_help',
|
||||
description: "Show Keyboard Shortcuts (this dialog)",
|
||||
icon: RiQuestionLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + L`],
|
||||
id: 'toggle_sidebar',
|
||||
description: "Toggle Session Sidebar",
|
||||
icon: RiLayoutLeftLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: ["Shift + Tab"],
|
||||
keys: ["Tab"],
|
||||
description: "Cycle Agent (chat input)",
|
||||
icon: RiAiAgentLine,
|
||||
},
|
||||
{
|
||||
keys: [`Shift + ${mod} + M`],
|
||||
id: 'open_model_selector',
|
||||
description: "Open Model Selector",
|
||||
icon: RiAiGenerate2,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`Shift + ${mod} + T`],
|
||||
id: 'cycle_thinking_variant',
|
||||
description: "Cycle Thinking Variant",
|
||||
icon: RiBrainAi3Line,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`Shift + Alt + ${mod} + N`],
|
||||
@@ -133,20 +107,70 @@ export const HelpDialog: React.FC = () => {
|
||||
category: "Session Management",
|
||||
items: [
|
||||
{
|
||||
keys: [`${mod} + N`],
|
||||
id: 'new_chat',
|
||||
description: settingsAutoCreateWorktree ? "Create New Session in Worktree" : "Create New Session",
|
||||
icon: settingsAutoCreateWorktree ? RiGitBranchLine : RiAddLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`Shift + ${mod} + N`],
|
||||
id: 'new_chat_worktree',
|
||||
description: settingsAutoCreateWorktree ? "Create New Session" : "Create New Session in Worktree",
|
||||
icon: settingsAutoCreateWorktree ? RiAddLine : RiGitBranchLine,
|
||||
keys: '',
|
||||
},
|
||||
{ keys: [`${mod} + I`], description: "Focus Chat Input", icon: RiText },
|
||||
{ id: 'focus_input', description: "Focus Chat Input", icon: RiText, keys: '' },
|
||||
{
|
||||
keys: ["Esc + Esc"],
|
||||
id: 'abort_run',
|
||||
description: "Abort active run (double press)",
|
||||
icon: RiCloseCircleLine,
|
||||
keys: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Panels",
|
||||
items: [
|
||||
{
|
||||
id: 'toggle_right_sidebar',
|
||||
description: 'Toggle Right Sidebar',
|
||||
icon: RiLayoutRightLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_git',
|
||||
description: 'Open Right Sidebar Git Tab',
|
||||
icon: RiGitBranchLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_files',
|
||||
description: 'Open Right Sidebar Files Tab',
|
||||
icon: RiLayoutRightLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'cycle_right_sidebar_tab',
|
||||
description: 'Cycle Right Sidebar Tab',
|
||||
icon: RiLayoutRightLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'toggle_terminal',
|
||||
description: 'Toggle Terminal Dock',
|
||||
icon: RiWindowLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'toggle_terminal_expanded',
|
||||
description: 'Toggle Terminal Expanded',
|
||||
icon: RiWindowLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'toggle_context_plan',
|
||||
description: 'Toggle Plan Context Panel',
|
||||
icon: RiTimeLine,
|
||||
keys: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -154,9 +178,10 @@ export const HelpDialog: React.FC = () => {
|
||||
category: "Interface",
|
||||
items: [
|
||||
{
|
||||
keys: [`${mod} + /`],
|
||||
id: 'cycle_theme',
|
||||
description: "Cycle Theme (Light → Dark → System)",
|
||||
icon: RiPaletteLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + 1...9`],
|
||||
@@ -164,14 +189,28 @@ export const HelpDialog: React.FC = () => {
|
||||
icon: RiLayoutLeftLine,
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + T`],
|
||||
id: 'open_timeline',
|
||||
description: "Open Timeline",
|
||||
icon: RiTimeLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
keys: [`${mod} + ,`],
|
||||
id: 'toggle_services_menu',
|
||||
description: 'Toggle Services Menu',
|
||||
icon: RiStackLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'cycle_services_tab',
|
||||
description: 'Cycle Services Tab',
|
||||
icon: RiStackLine,
|
||||
keys: '',
|
||||
},
|
||||
{
|
||||
id: 'open_settings',
|
||||
description: "Open Settings",
|
||||
icon: RiSettings3Line,
|
||||
keys: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -198,38 +237,41 @@ export const HelpDialog: React.FC = () => {
|
||||
{section.category}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{section.items.map((shortcut, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut.icon && (
|
||||
<shortcut.icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
{section.items.map((shortcut, index) => {
|
||||
const displayKeys = shortcut.id
|
||||
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
|
||||
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-1 px-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut.icon && (
|
||||
<shortcut.icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="typography-meta">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => (
|
||||
<React.Fragment key={`${keyCombo}-${i}`}>
|
||||
{i > 0 && (
|
||||
<span className="typography-meta text-muted-foreground mx-1">
|
||||
or
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
|
||||
{keyCombo}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(Array.isArray(shortcut.keys)
|
||||
? shortcut.keys
|
||||
: shortcut.keys.split(" / ")
|
||||
).map((keyCombo: string, i: number) => (
|
||||
<React.Fragment key={`${keyCombo}-${i}`}>
|
||||
{i > 0 && (
|
||||
<span className="typography-meta text-muted-foreground mx-1">
|
||||
or
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
|
||||
{renderKeyCombo(keyCombo)}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -242,7 +284,7 @@ export const HelpDialog: React.FC = () => {
|
||||
<p className="font-medium mb-1">Pro Tips:</p>
|
||||
<ul className="space-y-0.5 typography-meta">
|
||||
<li>
|
||||
• Use Command Palette ({mod} + K) to quickly access all
|
||||
• Use Command Palette ({renderShortcut('open_command_palette', `${mod} K`, shortcutOverrides)}) to quickly access all
|
||||
actions
|
||||
</li>
|
||||
<li>
|
||||
|
||||
Reference in New Issue
Block a user