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:
Nelson Pires
2026-02-20 15:40:25 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 47cecfc356
commit d9370f3af5
9 changed files with 1305 additions and 140 deletions
+87 -13
View File
@@ -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>