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 { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
|
||||||
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||||
import {
|
import {
|
||||||
getAllModelFamilies,
|
getAllModelFamilies,
|
||||||
getDisplayModelName,
|
getDisplayModelName,
|
||||||
@@ -148,6 +149,7 @@ export const Header: React.FC = () => {
|
|||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
|
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||||
|
|
||||||
const { getCurrentModel } = useConfigStore();
|
const { getCurrentModel } = useConfigStore();
|
||||||
const runtimeApis = useRuntimeAPIs();
|
const runtimeApis = useRuntimeAPIs();
|
||||||
@@ -1064,6 +1066,10 @@ export const Header: React.FC = () => {
|
|||||||
return base;
|
return base;
|
||||||
}, [diffFileCount, isMobile, showPlanTab]);
|
}, [diffFileCount, isMobile, showPlanTab]);
|
||||||
|
|
||||||
|
const shortcutLabel = React.useCallback((actionId: string) => {
|
||||||
|
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||||
|
}, [shortcutOverrides]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files')) {
|
if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files')) {
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
@@ -1114,6 +1120,65 @@ export const Header: React.FC = () => {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [tabs, setActiveMainTab, showProjectTabs, projects, activeProjectId, setActiveProject]);
|
}, [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 renderTab = (tab: TabConfig) => {
|
||||||
const isActive = activeMainTab === tab.id;
|
const isActive = activeMainTab === tab.id;
|
||||||
const isDiffTab = tab.icon === 'diff';
|
const isDiffTab = tab.icon === 'diff';
|
||||||
@@ -1453,14 +1518,21 @@ export const Header: React.FC = () => {
|
|||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
>
|
>
|
||||||
<button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
onClick={handleOpenSessionSwitcher}
|
<button
|
||||||
aria-label="Open sessions"
|
type="button"
|
||||||
className={`${headerIconButtonClass} mr-2 shrink-0`}
|
onClick={handleOpenSessionSwitcher}
|
||||||
>
|
aria-label="Open sessions"
|
||||||
<RiLayoutLeftLine className="h-5 w-5" />
|
className={`${headerIconButtonClass} mr-2 shrink-0`}
|
||||||
</button>
|
>
|
||||||
|
<RiLayoutLeftLine className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Open sessions ({shortcutLabel('toggle_sidebar')})</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Project tabs */}
|
{/* Project tabs */}
|
||||||
{showProjectTabs && (
|
{showProjectTabs && (
|
||||||
@@ -1683,7 +1755,7 @@ export const Header: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Plan</p>
|
<p>Plan ({shortcutLabel('toggle_context_plan')})</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -1721,7 +1793,9 @@ export const Header: React.FC = () => {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -1954,7 +2028,7 @@ export const Header: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Terminal panel</p>
|
<p>Terminal panel ({shortcutLabel('toggle_terminal')})</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -1970,7 +2044,7 @@ export const Header: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Right sidebar</p>
|
<p>Right sidebar ({shortcutLabel('toggle_right_sidebar')})</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -2407,7 +2481,7 @@ export const Header: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{updateAvailable ? 'Settings (Update available)' : 'Settings'}</p>
|
<p>{updateAvailable ? `Settings (Update available) (${shortcutLabel('open_settings')})` : `Settings (${shortcutLabel('open_settings')})`}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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 { GitHubSettings } from './GitHubSettings';
|
||||||
import { VoiceSettings } from './VoiceSettings';
|
import { VoiceSettings } from './VoiceSettings';
|
||||||
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
||||||
|
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||||
@@ -65,6 +66,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
|||||||
return <ChatSectionContent />;
|
return <ChatSectionContent />;
|
||||||
case 'sessions':
|
case 'sessions':
|
||||||
return <SessionsSectionContent />;
|
return <SessionsSectionContent />;
|
||||||
|
case 'shortcuts':
|
||||||
|
return <ShortcutsSectionContent />;
|
||||||
case 'git':
|
case 'git':
|
||||||
return <GitSectionContent />;
|
return <GitSectionContent />;
|
||||||
case 'github':
|
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)
|
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile)
|
||||||
const VisualSectionContent: React.FC = () => {
|
const VisualSectionContent: React.FC = () => {
|
||||||
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />;
|
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys']} />;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AboutSettings } from './AboutSettings';
|
|||||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||||
import { cn } from '@/lib/utils';
|
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 {
|
interface OpenChamberSidebarProps {
|
||||||
selectedSection: OpenChamberSection;
|
selectedSection: OpenChamberSection;
|
||||||
@@ -35,6 +35,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
|||||||
label: 'Chat',
|
label: 'Chat',
|
||||||
items: ['Tools', 'Diff', 'Reasoning'],
|
items: ['Tools', 'Diff', 'Reasoning'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'shortcuts',
|
||||||
|
label: 'Shortcuts',
|
||||||
|
items: ['Keyboard', 'Overrides'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'sessions',
|
id: 'sessions',
|
||||||
label: 'Sessions',
|
label: 'Sessions',
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
||||||
import { getModifierLabel } from '@/lib/utils';
|
|
||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
|
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||||
|
|
||||||
export const CommandPalette: React.FC = () => {
|
export const CommandPalette: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -29,6 +29,13 @@ export const CommandPalette: React.FC = () => {
|
|||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
setTimelineDialogOpen,
|
setTimelineDialogOpen,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
toggleRightSidebar,
|
||||||
|
setRightSidebarOpen,
|
||||||
|
setRightSidebarTab,
|
||||||
|
toggleBottomTerminal,
|
||||||
|
setBottomTerminalExpanded,
|
||||||
|
isBottomTerminalExpanded,
|
||||||
|
shortcutOverrides,
|
||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -105,6 +112,33 @@ export const CommandPalette: React.FC = () => {
|
|||||||
handleClose();
|
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 = () => {
|
const handleOpenTimeline = () => {
|
||||||
setTimelineDialogOpen(true);
|
setTimelineDialogOpen(true);
|
||||||
handleClose();
|
handleClose();
|
||||||
@@ -115,6 +149,10 @@ export const CommandPalette: React.FC = () => {
|
|||||||
return directorySessions.slice(0, 5);
|
return directorySessions.slice(0, 5);
|
||||||
}, [directorySessions]);
|
}, [directorySessions]);
|
||||||
|
|
||||||
|
const shortcut = React.useCallback((actionId: string) => {
|
||||||
|
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||||
|
}, [shortcutOverrides]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={isCommandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
<CommandDialog open={isCommandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||||
<CommandInput placeholder="Type a command or search..." />
|
<CommandInput placeholder="Type a command or search..." />
|
||||||
@@ -125,51 +163,76 @@ export const CommandPalette: React.FC = () => {
|
|||||||
<CommandItem onSelect={handleOpenSessionList}>
|
<CommandItem onSelect={handleOpenSessionList}>
|
||||||
<RiLayoutLeftLine className="mr-2 h-4 w-4" />
|
<RiLayoutLeftLine className="mr-2 h-4 w-4" />
|
||||||
<span>Open Session List</span>
|
<span>Open Session List</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + L</CommandShortcut>
|
<CommandShortcut>{shortcut('toggle_sidebar')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleCreateSession}>
|
<CommandItem onSelect={handleCreateSession}>
|
||||||
<RiAddLine className="mr-2 h-4 w-4" />
|
<RiAddLine className="mr-2 h-4 w-4" />
|
||||||
<span>New Session</span>
|
<span>New Session</span>
|
||||||
<CommandShortcut>
|
<CommandShortcut>
|
||||||
{settingsAutoCreateWorktree ? `Shift + ${getModifierLabel()} + N` : `${getModifierLabel()} + N`}
|
{settingsAutoCreateWorktree ? shortcut('new_chat_worktree') : shortcut('new_chat')}
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleCreateWorktreeSession}>
|
<CommandItem onSelect={handleCreateWorktreeSession}>
|
||||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||||
<span>New Session with Worktree</span>
|
<span>New Session with Worktree</span>
|
||||||
<CommandShortcut>
|
<CommandShortcut>
|
||||||
{settingsAutoCreateWorktree ? `${getModifierLabel()} + N` : `Shift + ${getModifierLabel()} + N`}
|
{settingsAutoCreateWorktree ? shortcut('new_chat') : shortcut('new_chat_worktree')}
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</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}>
|
<CommandItem onSelect={handleShowHelp}>
|
||||||
<RiQuestionLine className="mr-2 h-4 w-4" />
|
<RiQuestionLine className="mr-2 h-4 w-4" />
|
||||||
<span>Keyboard Shortcuts</span>
|
<span>Keyboard Shortcuts</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + .</CommandShortcut>
|
<CommandShortcut>{shortcut('open_help')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenDiffPanel}>
|
<CommandItem onSelect={handleOpenDiffPanel}>
|
||||||
<RiCodeLine className="mr-2 h-4 w-4" />
|
<RiCodeLine className="mr-2 h-4 w-4" />
|
||||||
<span>Open Diff Panel</span>
|
<span>Open Diff Panel</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + 2</CommandShortcut>
|
<CommandShortcut>{shortcut('open_diff_panel')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenTerminal}>
|
<CommandItem onSelect={handleOpenTerminal}>
|
||||||
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
|
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
|
||||||
<span>Open Terminal</span>
|
<span>Open Terminal</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + 3</CommandShortcut>
|
<CommandShortcut>{shortcut('open_terminal_panel')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenGitPanel}>
|
<CommandItem onSelect={handleOpenGitPanel}>
|
||||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||||
<span>Open Git Panel</span>
|
<span>Open Git Panel</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + 4</CommandShortcut>
|
<CommandShortcut>{shortcut('open_git_panel')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenTimeline}>
|
<CommandItem onSelect={handleOpenTimeline}>
|
||||||
<RiTimeLine className="mr-2 h-4 w-4" />
|
<RiTimeLine className="mr-2 h-4 w-4" />
|
||||||
<span>Open Timeline</span>
|
<span>Open Timeline</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + T</CommandShortcut>
|
<CommandShortcut>{shortcut('open_timeline')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenSettings}>
|
<CommandItem onSelect={handleOpenSettings}>
|
||||||
<RiSettings3Line className="mr-2 h-4 w-4" />
|
<RiSettings3Line className="mr-2 h-4 w-4" />
|
||||||
<span>Open Settings</span>
|
<span>Open Settings</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + ,</CommandShortcut>
|
<CommandShortcut>{shortcut('open_settings')}</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -12,66 +12,31 @@ import {
|
|||||||
RiAddLine,
|
RiAddLine,
|
||||||
RiAiAgentLine,
|
RiAiAgentLine,
|
||||||
RiAiGenerate2,
|
RiAiGenerate2,
|
||||||
RiArrowUpSLine,
|
|
||||||
RiBrainAi3Line,
|
RiBrainAi3Line,
|
||||||
RiCloseCircleLine,
|
RiCloseCircleLine,
|
||||||
RiCommandLine,
|
RiCommandLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiLayoutLeftLine,
|
RiLayoutLeftLine,
|
||||||
|
RiLayoutRightLine,
|
||||||
RiPaletteLine,
|
RiPaletteLine,
|
||||||
RiQuestionLine,
|
RiQuestionLine,
|
||||||
RiSettings3Line,
|
RiSettings3Line,
|
||||||
|
RiStackLine,
|
||||||
RiText,
|
RiText,
|
||||||
RiTimeLine,
|
RiTimeLine,
|
||||||
RiWindowLine,
|
RiWindowLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { getModifierLabel } from "@/lib/utils";
|
import {
|
||||||
|
getEffectiveShortcutCombo,
|
||||||
const renderKeyToken = (token: string, index: number) => {
|
getShortcutAction,
|
||||||
const normalized = token.trim().toLowerCase();
|
getModifierLabel,
|
||||||
|
formatShortcutForDisplay,
|
||||||
if (normalized === "ctrl" || normalized === "control") {
|
} from "@/lib/shortcuts";
|
||||||
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>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShortcutIcon = React.ComponentType<{ className?: string }>;
|
type ShortcutIcon = React.ComponentType<{ className?: string }>;
|
||||||
|
|
||||||
type ShortcutItem = {
|
type ShortcutItem = {
|
||||||
|
id?: string;
|
||||||
keys: string | string[];
|
keys: string | string[];
|
||||||
description: string;
|
description: string;
|
||||||
icon: ShortcutIcon | null;
|
icon: ShortcutIcon | null;
|
||||||
@@ -82,10 +47,14 @@ type ShortcutSection = {
|
|||||||
items: ShortcutItem[];
|
items: ShortcutItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HelpDialog: React.FC = () => {
|
const renderShortcut = (id: string, fallbackCombo: string, overrides: Record<string, string>) => {
|
||||||
const { isHelpDialogOpen, setHelpDialogOpen } = useUIStore();
|
const action = getShortcutAction(id);
|
||||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
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 mod = getModifierLabel();
|
||||||
|
|
||||||
const shortcuts: ShortcutSection[] = [
|
const shortcuts: ShortcutSection[] = [
|
||||||
@@ -93,34 +62,39 @@ export const HelpDialog: React.FC = () => {
|
|||||||
category: "Navigation & Commands",
|
category: "Navigation & Commands",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
keys: [`${mod} + K`],
|
id: 'open_command_palette',
|
||||||
description: "Open Command Palette",
|
description: "Open Command Palette",
|
||||||
icon: RiCommandLine,
|
icon: RiCommandLine,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + .`],
|
id: 'open_help',
|
||||||
description: "Show Keyboard Shortcuts (this dialog)",
|
description: "Show Keyboard Shortcuts (this dialog)",
|
||||||
icon: RiQuestionLine,
|
icon: RiQuestionLine,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + L`],
|
id: 'toggle_sidebar',
|
||||||
description: "Toggle Session Sidebar",
|
description: "Toggle Session Sidebar",
|
||||||
icon: RiLayoutLeftLine,
|
icon: RiLayoutLeftLine,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: ["Shift + Tab"],
|
keys: ["Tab"],
|
||||||
description: "Cycle Agent (chat input)",
|
description: "Cycle Agent (chat input)",
|
||||||
icon: RiAiAgentLine,
|
icon: RiAiAgentLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`Shift + ${mod} + M`],
|
id: 'open_model_selector',
|
||||||
description: "Open Model Selector",
|
description: "Open Model Selector",
|
||||||
icon: RiAiGenerate2,
|
icon: RiAiGenerate2,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`Shift + ${mod} + T`],
|
id: 'cycle_thinking_variant',
|
||||||
description: "Cycle Thinking Variant",
|
description: "Cycle Thinking Variant",
|
||||||
icon: RiBrainAi3Line,
|
icon: RiBrainAi3Line,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`Shift + Alt + ${mod} + N`],
|
keys: [`Shift + Alt + ${mod} + N`],
|
||||||
@@ -133,20 +107,70 @@ export const HelpDialog: React.FC = () => {
|
|||||||
category: "Session Management",
|
category: "Session Management",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
keys: [`${mod} + N`],
|
id: 'new_chat',
|
||||||
description: settingsAutoCreateWorktree ? "Create New Session in Worktree" : "Create New Session",
|
description: settingsAutoCreateWorktree ? "Create New Session in Worktree" : "Create New Session",
|
||||||
icon: settingsAutoCreateWorktree ? RiGitBranchLine : RiAddLine,
|
icon: settingsAutoCreateWorktree ? RiGitBranchLine : RiAddLine,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`Shift + ${mod} + N`],
|
id: 'new_chat_worktree',
|
||||||
description: settingsAutoCreateWorktree ? "Create New Session" : "Create New Session in Worktree",
|
description: settingsAutoCreateWorktree ? "Create New Session" : "Create New Session in Worktree",
|
||||||
icon: settingsAutoCreateWorktree ? RiAddLine : RiGitBranchLine,
|
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)",
|
description: "Abort active run (double press)",
|
||||||
icon: RiCloseCircleLine,
|
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",
|
category: "Interface",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
keys: [`${mod} + /`],
|
id: 'cycle_theme',
|
||||||
description: "Cycle Theme (Light → Dark → System)",
|
description: "Cycle Theme (Light → Dark → System)",
|
||||||
icon: RiPaletteLine,
|
icon: RiPaletteLine,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + 1...9`],
|
keys: [`${mod} + 1...9`],
|
||||||
@@ -164,14 +189,28 @@ export const HelpDialog: React.FC = () => {
|
|||||||
icon: RiLayoutLeftLine,
|
icon: RiLayoutLeftLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + T`],
|
id: 'open_timeline',
|
||||||
description: "Open Timeline",
|
description: "Open Timeline",
|
||||||
icon: RiTimeLine,
|
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",
|
description: "Open Settings",
|
||||||
icon: RiSettings3Line,
|
icon: RiSettings3Line,
|
||||||
|
keys: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -198,38 +237,41 @@ export const HelpDialog: React.FC = () => {
|
|||||||
{section.category}
|
{section.category}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{section.items.map((shortcut, index) => (
|
{section.items.map((shortcut, index) => {
|
||||||
<div
|
const displayKeys = shortcut.id
|
||||||
key={index}
|
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
|
||||||
className="flex items-center justify-between py-1 px-2"
|
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
return (
|
||||||
{shortcut.icon && (
|
<div
|
||||||
<shortcut.icon className="h-3.5 w-3.5 text-muted-foreground" />
|
key={index}
|
||||||
)}
|
className="flex items-center justify-between py-1 px-2"
|
||||||
<span className="typography-meta">
|
>
|
||||||
{shortcut.description}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
{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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -242,7 +284,7 @@ export const HelpDialog: React.FC = () => {
|
|||||||
<p className="font-medium mb-1">Pro Tips:</p>
|
<p className="font-medium mb-1">Pro Tips:</p>
|
||||||
<ul className="space-y-0.5 typography-meta">
|
<ul className="space-y-0.5 typography-meta">
|
||||||
<li>
|
<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
|
actions
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { hasModifier } from '@/lib/utils';
|
|
||||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||||
|
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||||
|
|
||||||
export const useKeyboardShortcuts = () => {
|
export const useKeyboardShortcuts = () => {
|
||||||
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
||||||
@@ -15,15 +15,26 @@ export const useKeyboardShortcuts = () => {
|
|||||||
toggleCommandPalette,
|
toggleCommandPalette,
|
||||||
toggleHelpDialog,
|
toggleHelpDialog,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
toggleRightSidebar,
|
||||||
|
setRightSidebarOpen,
|
||||||
|
setRightSidebarTab,
|
||||||
|
toggleBottomTerminal,
|
||||||
|
setBottomTerminalExpanded,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setModelSelectorOpen,
|
setModelSelectorOpen,
|
||||||
|
shortcutOverrides,
|
||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
const { themeMode, setThemeMode } = useThemeSystem();
|
const { themeMode, setThemeMode } = useThemeSystem();
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
||||||
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const themeModeRef = React.useRef(themeMode);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
themeModeRef.current = themeMode;
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
const resetAbortPriming = React.useCallback(() => {
|
const resetAbortPriming = React.useCallback(() => {
|
||||||
if (abortPrimedTimeoutRef.current) {
|
if (abortPrimedTimeoutRef.current) {
|
||||||
@@ -35,70 +46,86 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}, [clearAbortPrompt]);
|
}, [clearAbortPrompt]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
|
||||||
|
|
||||||
if (hasModifier(e) && e.key === 'k') {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (eventMatchesShortcut(e, combo('open_command_palette'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleCommandPalette();
|
toggleCommandPalette();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
if (eventMatchesShortcut(e, combo('open_status'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void showOpenCodeStatus();
|
void showOpenCodeStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && e.key === '.') {
|
if (eventMatchesShortcut(e, combo('open_help'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleHelpDialog();
|
toggleHelpDialog();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
|
if (eventMatchesShortcut(e, combo('new_chat')) || eventMatchesShortcut(e, combo('new_chat_worktree'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const isVSCode = isVSCodeRuntime();
|
const isVSCode = isVSCodeRuntime();
|
||||||
const autoWorktree = useConfigStore.getState().settingsAutoCreateWorktree;
|
const autoWorktree = useConfigStore.getState().settingsAutoCreateWorktree;
|
||||||
// If autoWorktree is true: Cmd+N -> Worktree, Cmd+Shift+N -> Standard
|
const matchedPrimaryShortcut = eventMatchesShortcut(e, combo('new_chat'));
|
||||||
// If autoWorktree is false: Cmd+N -> Standard, Cmd+Shift+N -> Worktree
|
const shouldCreateWorktree = isVSCode
|
||||||
// VS Code: always open standard session (no worktree support)
|
? false
|
||||||
const shouldCreateWorktree = isVSCode ? false : (autoWorktree ? !e.shiftKey : e.shiftKey);
|
: (matchedPrimaryShortcut ? autoWorktree : !autoWorktree);
|
||||||
|
|
||||||
if (shouldCreateWorktree) {
|
if (shouldCreateWorktree) {
|
||||||
// Create new session with auto-generated worktree
|
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
setSessionSwitcherOpen(false);
|
setSessionSwitcherOpen(false);
|
||||||
createWorktreeSession();
|
createWorktreeSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Open a new session without worktree
|
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
setSessionSwitcherOpen(false);
|
setSessionSwitcherOpen(false);
|
||||||
openNewSessionDraft();
|
openNewSessionDraft();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && e.key === '/') {
|
if (eventMatchesShortcut(e, combo('cycle_theme'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||||
const currentIndex = modes.indexOf(themeMode);
|
const activeElement = document.activeElement as HTMLElement | null;
|
||||||
|
const currentIndex = modes.indexOf(themeModeRef.current);
|
||||||
const nextIndex = (currentIndex + 1) % modes.length;
|
const nextIndex = (currentIndex + 1) % modes.length;
|
||||||
setThemeMode(modes[nextIndex]);
|
setThemeMode(modes[nextIndex]);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!document.hasFocus()) {
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
if (activeElement && document.contains(activeElement)) {
|
||||||
|
activeElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
|
if (eventMatchesShortcut(e, combo('open_timeline'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
|
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
|
||||||
setTimelineDialogOpen(!isTimelineDialogOpen);
|
setTimelineDialogOpen(!isTimelineDialogOpen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && !e.shiftKey && e.key === ',') {
|
if (eventMatchesShortcut(e, combo('open_settings'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { isSettingsDialogOpen } = useUIStore.getState();
|
const { isSettingsDialogOpen } = useUIStore.getState();
|
||||||
setSettingsDialogOpen(!isSettingsDialogOpen);
|
setSettingsDialogOpen(!isSettingsDialogOpen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'l') {
|
if (eventMatchesShortcut(e, combo('toggle_sidebar'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
|
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -109,15 +136,83 @@ export const useKeyboardShortcuts = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'i') {
|
if (eventMatchesShortcut(e, combo('focus_input'))) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||||
textarea?.focus();
|
textarea?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventMatchesShortcut(e, combo('toggle_right_sidebar'))) {
|
||||||
|
const { isMobile } = useUIStore.getState();
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
toggleRightSidebar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventMatchesShortcut(e, combo('open_right_sidebar_git'))) {
|
||||||
|
const { isMobile } = useUIStore.getState();
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
setRightSidebarOpen(true);
|
||||||
|
setRightSidebarTab('git');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventMatchesShortcut(e, combo('open_right_sidebar_files'))) {
|
||||||
|
const { isMobile } = useUIStore.getState();
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
setRightSidebarOpen(true);
|
||||||
|
setRightSidebarTab('files');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventMatchesShortcut(e, combo('cycle_right_sidebar_tab'))) {
|
||||||
|
const { isMobile, rightSidebarTab } = useUIStore.getState();
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = ['git', 'files'] as const;
|
||||||
|
const currentIndex = tabs.indexOf(rightSidebarTab);
|
||||||
|
const nextTab = tabs[(currentIndex + 1) % tabs.length];
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setRightSidebarOpen(true);
|
||||||
|
setRightSidebarTab(nextTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
|
||||||
|
const { isMobile } = useUIStore.getState();
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
toggleBottomTerminal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) {
|
||||||
|
const { isMobile, isBottomTerminalExpanded } = useUIStore.getState();
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
setBottomTerminalExpanded(!isBottomTerminalExpanded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
|
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
|
||||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'm') {
|
if (eventMatchesShortcut(e, combo('open_model_selector'))) {
|
||||||
const {
|
const {
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
isCommandPaletteOpen,
|
isCommandPaletteOpen,
|
||||||
@@ -147,7 +242,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M)
|
// Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M)
|
||||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 't') {
|
if (eventMatchesShortcut(e, combo('cycle_thinking_variant'))) {
|
||||||
const {
|
const {
|
||||||
isSettingsDialogOpen,
|
isSettingsDialogOpen,
|
||||||
isCommandPaletteOpen,
|
isCommandPaletteOpen,
|
||||||
@@ -276,16 +371,21 @@ export const useKeyboardShortcuts = () => {
|
|||||||
toggleCommandPalette,
|
toggleCommandPalette,
|
||||||
toggleHelpDialog,
|
toggleHelpDialog,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
|
toggleRightSidebar,
|
||||||
|
setRightSidebarOpen,
|
||||||
|
setRightSidebarTab,
|
||||||
|
toggleBottomTerminal,
|
||||||
|
setBottomTerminalExpanded,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setModelSelectorOpen,
|
setModelSelectorOpen,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
themeMode,
|
|
||||||
working,
|
working,
|
||||||
armAbortPrompt,
|
armAbortPrompt,
|
||||||
resetAbortPriming,
|
resetAbortPriming,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
shortcutOverrides,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { isMacOS } from '@/lib/utils';
|
||||||
|
import { isTauriShell } from '@/lib/desktop';
|
||||||
|
|
||||||
|
export type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl';
|
||||||
|
export type ShortcutKey = string;
|
||||||
|
export type ShortcutCombo = string;
|
||||||
|
|
||||||
|
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
|
||||||
|
|
||||||
|
export interface ShortcutAction {
|
||||||
|
id: string;
|
||||||
|
defaultCombo: ShortcutCombo;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
customizable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedShortcut {
|
||||||
|
modifiers: Set<ShortcutModifier>;
|
||||||
|
key: ShortcutKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
|
||||||
|
'mod': 'mod',
|
||||||
|
'shift': 'shift',
|
||||||
|
'alt': 'alt',
|
||||||
|
'option': 'alt',
|
||||||
|
'ctrl': 'ctrl',
|
||||||
|
'meta': 'mod',
|
||||||
|
'cmd': 'mod',
|
||||||
|
'command': 'mod',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
|
||||||
|
'mod': isMacOS() && isTauriShell() ? '⌘' : 'Ctrl',
|
||||||
|
'shift': '⇧',
|
||||||
|
'alt': '⌥',
|
||||||
|
'option': '⌥',
|
||||||
|
'ctrl': '⌃',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_LABEL_MAP: Record<string, string> = {
|
||||||
|
'comma': ',',
|
||||||
|
'period': '.',
|
||||||
|
'enter': 'Enter',
|
||||||
|
'escape': 'Esc',
|
||||||
|
'tab': 'Tab',
|
||||||
|
'space': 'Space',
|
||||||
|
'backspace': '⌫',
|
||||||
|
'delete': '⌦',
|
||||||
|
'arrowup': '↑',
|
||||||
|
'arrowdown': '↓',
|
||||||
|
'arrowleft': '←',
|
||||||
|
'arrowright': '→',
|
||||||
|
'home': 'Home',
|
||||||
|
'end': 'End',
|
||||||
|
'pageup': 'Page Up',
|
||||||
|
'pagedown': 'Page Down',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt'];
|
||||||
|
|
||||||
|
const SHIFTED_KEY_BASE_MAP: Record<string, string> = {
|
||||||
|
'{': '[',
|
||||||
|
'}': ']',
|
||||||
|
':': ';',
|
||||||
|
'"': "'",
|
||||||
|
'<': ',',
|
||||||
|
'>': '.',
|
||||||
|
'?': '/',
|
||||||
|
'|': '\\',
|
||||||
|
'~': '`',
|
||||||
|
'!': '1',
|
||||||
|
'@': '2',
|
||||||
|
'#': '3',
|
||||||
|
'$': '4',
|
||||||
|
'%': '5',
|
||||||
|
'^': '6',
|
||||||
|
'&': '7',
|
||||||
|
'*': '8',
|
||||||
|
'(': '9',
|
||||||
|
')': '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUnassignedShortcut(combo: ShortcutCombo): boolean {
|
||||||
|
return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyToShortcutToken(key: string): string {
|
||||||
|
const lowered = key.toLowerCase();
|
||||||
|
|
||||||
|
if (lowered === ',') return 'comma';
|
||||||
|
if (lowered === '.') return 'period';
|
||||||
|
if (lowered === ' ') return 'space';
|
||||||
|
if (lowered === 'esc') return 'escape';
|
||||||
|
if (lowered === '+') return 'plus';
|
||||||
|
if (lowered === '-' || lowered === '_') return 'minus';
|
||||||
|
if (lowered === 'arrowup') return 'arrowup';
|
||||||
|
if (lowered === 'arrowdown') return 'arrowdown';
|
||||||
|
if (lowered === 'arrowleft') return 'arrowleft';
|
||||||
|
if (lowered === 'arrowright') return 'arrowright';
|
||||||
|
|
||||||
|
return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||||
|
{
|
||||||
|
id: 'open_command_palette',
|
||||||
|
defaultCombo: 'mod+k',
|
||||||
|
label: 'Open command palette',
|
||||||
|
description: 'Open the command palette',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'focus_input',
|
||||||
|
defaultCombo: 'mod+i',
|
||||||
|
label: 'Focus input',
|
||||||
|
description: 'Focus the chat input field',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_status',
|
||||||
|
defaultCombo: 'mod+shift+l',
|
||||||
|
label: 'Open OpenCode status',
|
||||||
|
description: 'Open the OpenCode status dialog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_settings',
|
||||||
|
defaultCombo: 'mod+comma',
|
||||||
|
label: 'Open settings',
|
||||||
|
description: 'Open the settings panel',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle_terminal',
|
||||||
|
defaultCombo: 'mod+j',
|
||||||
|
label: 'Toggle terminal dock',
|
||||||
|
description: 'Toggle the bottom terminal dock',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle_terminal_expanded',
|
||||||
|
defaultCombo: 'mod+shift+j',
|
||||||
|
label: 'Toggle terminal expanded',
|
||||||
|
description: 'Toggle terminal expanded or collapsed',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle_files',
|
||||||
|
defaultCombo: 'mod+shift+f',
|
||||||
|
label: 'Toggle files',
|
||||||
|
description: 'Toggle the files panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle_sidebar',
|
||||||
|
defaultCombo: 'mod+l',
|
||||||
|
label: 'Toggle sidebar',
|
||||||
|
description: 'Toggle the session sidebar',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle_right_sidebar',
|
||||||
|
defaultCombo: 'mod+b',
|
||||||
|
label: 'Toggle right sidebar',
|
||||||
|
description: 'Toggle the right sidebar',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_right_sidebar_git',
|
||||||
|
defaultCombo: 'mod+shift+g',
|
||||||
|
label: 'Open right sidebar Git tab',
|
||||||
|
description: 'Open right sidebar and select Git',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_right_sidebar_files',
|
||||||
|
defaultCombo: 'mod+shift+f',
|
||||||
|
label: 'Open right sidebar Files tab',
|
||||||
|
description: 'Open right sidebar and select Files',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cycle_right_sidebar_tab',
|
||||||
|
defaultCombo: 'mod+shift+]',
|
||||||
|
label: 'Cycle right sidebar tab',
|
||||||
|
description: 'Cycle through right sidebar tabs',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new_chat',
|
||||||
|
defaultCombo: 'mod+n',
|
||||||
|
label: 'New session',
|
||||||
|
description: 'Start a new session',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new_chat_worktree',
|
||||||
|
defaultCombo: 'mod+shift+n',
|
||||||
|
label: 'New session with worktree',
|
||||||
|
description: 'Start a new session in a worktree',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'submit_message',
|
||||||
|
defaultCombo: 'mod+enter',
|
||||||
|
label: 'Submit message',
|
||||||
|
description: 'Submit the current message',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clear_input',
|
||||||
|
defaultCombo: 'escape',
|
||||||
|
label: 'Clear input',
|
||||||
|
description: 'Clear the input field',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_diff_panel',
|
||||||
|
defaultCombo: 'mod+2',
|
||||||
|
label: 'Open diff panel',
|
||||||
|
description: 'Switch to the diff panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_terminal_panel',
|
||||||
|
defaultCombo: 'mod+3',
|
||||||
|
label: 'Open terminal panel',
|
||||||
|
description: 'Switch to the terminal panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_git_panel',
|
||||||
|
defaultCombo: 'mod+4',
|
||||||
|
label: 'Open git panel',
|
||||||
|
description: 'Switch to the git panel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_timeline',
|
||||||
|
defaultCombo: 'mod+t',
|
||||||
|
label: 'Open timeline',
|
||||||
|
description: 'Open the timeline dialog',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_help',
|
||||||
|
defaultCombo: 'mod+.',
|
||||||
|
label: 'Open keyboard shortcuts',
|
||||||
|
description: 'Show the keyboard shortcuts help',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle_context_plan',
|
||||||
|
defaultCombo: 'mod+shift+p',
|
||||||
|
label: 'Toggle plan context panel',
|
||||||
|
description: 'Open or close plan in the context panel',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle_services_menu',
|
||||||
|
defaultCombo: 'mod+shift+s',
|
||||||
|
label: 'Toggle services menu',
|
||||||
|
description: 'Open or close the services menu',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cycle_services_tab',
|
||||||
|
defaultCombo: 'mod+shift+[',
|
||||||
|
label: 'Cycle services tab',
|
||||||
|
description: 'Cycle through tabs in the services menu',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cycle_theme',
|
||||||
|
defaultCombo: 'mod+/',
|
||||||
|
label: 'Cycle theme',
|
||||||
|
description: 'Cycle between light, dark, and system theme',
|
||||||
|
customizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_model_selector',
|
||||||
|
defaultCombo: 'mod+shift+m',
|
||||||
|
label: 'Open model selector',
|
||||||
|
description: 'Open model selector while in chat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cycle_thinking_variant',
|
||||||
|
defaultCombo: 'mod+shift+t',
|
||||||
|
label: 'Cycle thinking variant',
|
||||||
|
description: 'Cycle thinking variant while in chat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'abort_run',
|
||||||
|
defaultCombo: 'escape',
|
||||||
|
label: 'Abort active run',
|
||||||
|
description: 'Abort the currently running task (double press)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_1',
|
||||||
|
defaultCombo: 'mod+1',
|
||||||
|
label: 'Switch to tab 1',
|
||||||
|
description: 'Switch to the first tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_2',
|
||||||
|
defaultCombo: 'mod+2',
|
||||||
|
label: 'Switch to tab 2',
|
||||||
|
description: 'Switch to the second tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_3',
|
||||||
|
defaultCombo: 'mod+3',
|
||||||
|
label: 'Switch to tab 3',
|
||||||
|
description: 'Switch to the third tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_4',
|
||||||
|
defaultCombo: 'mod+4',
|
||||||
|
label: 'Switch to tab 4',
|
||||||
|
description: 'Switch to the fourth tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_5',
|
||||||
|
defaultCombo: 'mod+5',
|
||||||
|
label: 'Switch to tab 5',
|
||||||
|
description: 'Switch to the fifth tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_6',
|
||||||
|
defaultCombo: 'mod+6',
|
||||||
|
label: 'Switch to tab 6',
|
||||||
|
description: 'Switch to the sixth tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_7',
|
||||||
|
defaultCombo: 'mod+7',
|
||||||
|
label: 'Switch to tab 7',
|
||||||
|
description: 'Switch to the seventh tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_8',
|
||||||
|
defaultCombo: 'mod+8',
|
||||||
|
label: 'Switch to tab 8',
|
||||||
|
description: 'Switch to the eighth tab or project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch_tab_9',
|
||||||
|
defaultCombo: 'mod+9',
|
||||||
|
label: 'Switch to tab 9',
|
||||||
|
description: 'Switch to the ninth tab or project',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
|
||||||
|
if (isUnassignedShortcut(combo)) {
|
||||||
|
return UNASSIGNED_SHORTCUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawParts = combo
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.split('+')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const modifiers = new Set<ShortcutModifier>();
|
||||||
|
let key = '';
|
||||||
|
|
||||||
|
for (const rawPart of rawParts) {
|
||||||
|
const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart;
|
||||||
|
const modifier = MODIFIER_KEY_MAP[part];
|
||||||
|
if (modifier) {
|
||||||
|
modifiers.add(modifier);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
key = part;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier));
|
||||||
|
return [...orderedModifiers, key].filter(Boolean).join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidShortcutCombo(combo: ShortcutCombo): boolean {
|
||||||
|
if (isUnassignedShortcut(combo)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseShortcut(combo);
|
||||||
|
return parsed.key.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShortcut(combo: ShortcutCombo): ParsedShortcut {
|
||||||
|
if (isUnassignedShortcut(combo)) {
|
||||||
|
return { modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeCombo(combo);
|
||||||
|
const parts = normalized.split('+');
|
||||||
|
const modifiers: Set<ShortcutModifier> = new Set();
|
||||||
|
let key: ShortcutKey = '';
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const modifier = MODIFIER_KEY_MAP[part];
|
||||||
|
if (modifier) {
|
||||||
|
modifiers.add(modifier);
|
||||||
|
} else {
|
||||||
|
key = part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modifiers, key };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatShortcutForDisplay(combo: ShortcutCombo): string {
|
||||||
|
if (isUnassignedShortcut(combo)) {
|
||||||
|
return 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseShortcut(combo);
|
||||||
|
|
||||||
|
if (!parsed.key && parsed.modifiers.size === 0) {
|
||||||
|
return 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const modifier of MODIFIER_PRIORITY) {
|
||||||
|
if (parsed.modifiers.has(modifier)) {
|
||||||
|
parts.push(DISPLAY_LABEL_MAP[modifier]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.key) {
|
||||||
|
const keyLabel = KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase();
|
||||||
|
parts.push(keyLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' + ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutAction(id: string): ShortcutAction | undefined {
|
||||||
|
return SHORTCUT_ACTIONS.find((action) => action.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllShortcutActions(): ReadonlyArray<ShortcutAction> {
|
||||||
|
return SHORTCUT_ACTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomizableShortcutActions(): ReadonlyArray<ShortcutAction> {
|
||||||
|
return SHORTCUT_ACTIONS.filter((action) => action.customizable === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveShortcutCombo(
|
||||||
|
actionId: string,
|
||||||
|
overrides?: Record<string, ShortcutCombo>
|
||||||
|
): ShortcutCombo {
|
||||||
|
const action = getShortcutAction(actionId);
|
||||||
|
if (!action) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = overrides?.[actionId];
|
||||||
|
if (typeof override === 'string') {
|
||||||
|
if (override.trim().toLowerCase() === UNASSIGNED_SHORTCUT) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeCombo(override);
|
||||||
|
if (normalized === UNASSIGNED_SHORTCUT) {
|
||||||
|
return UNASSIGNED_SHORTCUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidShortcutCombo(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return action.defaultCombo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveShortcutLabel(
|
||||||
|
actionId: string,
|
||||||
|
overrides?: Record<string, ShortcutCombo>
|
||||||
|
): string {
|
||||||
|
const combo = getEffectiveShortcutCombo(actionId, overrides);
|
||||||
|
if (!combo) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return formatShortcutForDisplay(combo);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
|
||||||
|
if (isUnassignedShortcut(combo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseShortcut(combo);
|
||||||
|
if (!parsed.modifiers.has('mod')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = parsed.key.toLowerCase();
|
||||||
|
const dangerousPrimary = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']);
|
||||||
|
return dangerousPrimary.has(key) && !parsed.modifiers.has('shift') && !parsed.modifiers.has('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventMatchesShortcut(
|
||||||
|
event: KeyboardEvent | React.KeyboardEvent,
|
||||||
|
shortcut: ShortcutAction | ShortcutCombo
|
||||||
|
): boolean {
|
||||||
|
const combo = typeof shortcut === 'string' ? shortcut : shortcut.defaultCombo;
|
||||||
|
if (isUnassignedShortcut(combo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseShortcut(combo);
|
||||||
|
|
||||||
|
const expectedMod = parsed.modifiers.has('mod');
|
||||||
|
const expectedShift = parsed.modifiers.has('shift');
|
||||||
|
const expectedAlt = parsed.modifiers.has('alt');
|
||||||
|
const expectedCtrl = parsed.modifiers.has('ctrl');
|
||||||
|
const isDesktopMac = isMacOS() && isTauriShell();
|
||||||
|
const isMac = isMacOS();
|
||||||
|
|
||||||
|
const modMatches = isDesktopMac
|
||||||
|
? event.metaKey
|
||||||
|
: isMac
|
||||||
|
? (event.metaKey || event.ctrlKey)
|
||||||
|
: event.ctrlKey;
|
||||||
|
|
||||||
|
if (expectedMod && !modMatches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedMod && event.metaKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedShift !== event.shiftKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedAlt !== event.altKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedCtrl) {
|
||||||
|
if (!event.ctrlKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
|
||||||
|
if (event.ctrlKey && !ctrlUsedAsMod) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventKey = keyToShortcutToken(event.key);
|
||||||
|
const expectedKey = keyToShortcutToken(parsed.key);
|
||||||
|
|
||||||
|
return eventKey === expectedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutLabel(id: string): string {
|
||||||
|
const action = getShortcutAction(id);
|
||||||
|
if (!action) return '';
|
||||||
|
|
||||||
|
const displayCombo = formatShortcutForDisplay(action.defaultCombo);
|
||||||
|
return `${displayCombo} - ${action.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModifierLabel(): string {
|
||||||
|
return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl';
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import type { SidebarSection } from '@/constants/sidebar';
|
import type { SidebarSection } from '@/constants/sidebar';
|
||||||
import { getSafeStorage } from './utils/safeStorage';
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||||
|
import type { ShortcutCombo } from '@/lib/shortcuts';
|
||||||
|
|
||||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
|
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
|
||||||
export type RightSidebarTab = 'git' | 'files';
|
export type RightSidebarTab = 'git' | 'files';
|
||||||
@@ -214,6 +215,8 @@ interface UIStore {
|
|||||||
persistChatDraft: boolean;
|
persistChatDraft: boolean;
|
||||||
isMobileSessionStatusBarCollapsed: boolean;
|
isMobileSessionStatusBarCollapsed: boolean;
|
||||||
|
|
||||||
|
shortcutOverrides: Record<string, ShortcutCombo>;
|
||||||
|
|
||||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
@@ -297,6 +300,9 @@ interface UIStore {
|
|||||||
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||||
openMultiRunLauncher: () => void;
|
openMultiRunLauncher: () => void;
|
||||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||||
|
setShortcutOverride: (actionId: string, combo: ShortcutCombo) => void;
|
||||||
|
clearShortcutOverride: (actionId: string) => void;
|
||||||
|
resetAllShortcutOverrides: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -384,6 +390,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
showTerminalQuickKeysOnDesktop: false,
|
showTerminalQuickKeysOnDesktop: false,
|
||||||
persistChatDraft: true,
|
persistChatDraft: true,
|
||||||
isMobileSessionStatusBarCollapsed: false,
|
isMobileSessionStatusBarCollapsed: false,
|
||||||
|
shortcutOverrides: {},
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -1103,11 +1110,32 @@ export const useUIStore = create<UIStore>()(
|
|||||||
setIsMobileSessionStatusBarCollapsed: (value) => {
|
setIsMobileSessionStatusBarCollapsed: (value) => {
|
||||||
set({ isMobileSessionStatusBarCollapsed: value });
|
set({ isMobileSessionStatusBarCollapsed: value });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setShortcutOverride: (actionId, combo) => {
|
||||||
|
set((state) => ({
|
||||||
|
shortcutOverrides: {
|
||||||
|
...state.shortcutOverrides,
|
||||||
|
[actionId]: combo,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearShortcutOverride: (actionId) => {
|
||||||
|
set((state) => {
|
||||||
|
const rest = { ...state.shortcutOverrides };
|
||||||
|
delete rest[actionId];
|
||||||
|
return { shortcutOverrides: rest };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetAllShortcutOverrides: () => {
|
||||||
|
set({ shortcutOverrides: {} });
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
storage: createJSONStorage(() => getSafeStorage()),
|
storage: createJSONStorage(() => getSafeStorage()),
|
||||||
version: 4,
|
version: 5,
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
if (!persistedState || typeof persistedState !== 'object') {
|
if (!persistedState || typeof persistedState !== 'object') {
|
||||||
return persistedState;
|
return persistedState;
|
||||||
@@ -1155,6 +1183,21 @@ export const useUIStore = create<UIStore>()(
|
|||||||
state.contextPanelByDirectory = {};
|
state.contextPanelByDirectory = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 5) {
|
||||||
|
if (!state.shortcutOverrides || typeof state.shortcutOverrides !== 'object') {
|
||||||
|
state.shortcutOverrides = {};
|
||||||
|
} else {
|
||||||
|
const overrides = state.shortcutOverrides as Record<string, unknown>;
|
||||||
|
const cleaned: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (typeof key === 'string' && typeof value === 'string') {
|
||||||
|
cleaned[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.shortcutOverrides = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
@@ -1205,6 +1248,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
maxLastMessageLength: state.maxLastMessageLength,
|
maxLastMessageLength: state.maxLastMessageLength,
|
||||||
persistChatDraft: state.persistChatDraft,
|
persistChatDraft: state.persistChatDraft,
|
||||||
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
||||||
|
shortcutOverrides: state.shortcutOverrides,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user