2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
|
|
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
2026-01-03 01:18:57 +01:00
|
|
|
import { hasModifier } from '@/lib/utils';
|
2026-01-07 22:06:28 +02:00
|
|
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
2026-01-07 23:37:32 +02:00
|
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
2026-01-16 00:26:25 +02:00
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-02-05 01:59:49 +02:00
|
|
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
export const useKeyboardShortcuts = () => {
|
2025-12-21 20:18:51 +02:00
|
|
|
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
2025-12-07 19:32:53 +02:00
|
|
|
const {
|
|
|
|
|
toggleCommandPalette,
|
|
|
|
|
toggleHelpDialog,
|
|
|
|
|
toggleSidebar,
|
|
|
|
|
setSessionSwitcherOpen,
|
|
|
|
|
setActiveMainTab,
|
|
|
|
|
setSettingsDialogOpen,
|
2026-01-02 13:08:14 +02:00
|
|
|
setModelSelectorOpen,
|
2025-12-07 19:32:53 +02:00
|
|
|
} = useUIStore();
|
|
|
|
|
const { themeMode, setThemeMode } = useThemeSystem();
|
|
|
|
|
const { working } = useAssistantStatus();
|
|
|
|
|
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
|
|
|
|
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
const resetAbortPriming = React.useCallback(() => {
|
|
|
|
|
if (abortPrimedTimeoutRef.current) {
|
|
|
|
|
clearTimeout(abortPrimedTimeoutRef.current);
|
|
|
|
|
abortPrimedTimeoutRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
abortPrimedUntilRef.current = null;
|
|
|
|
|
clearAbortPrompt();
|
|
|
|
|
}, [clearAbortPrompt]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
|
2026-01-03 01:18:57 +01:00
|
|
|
if (hasModifier(e) && e.key === 'k') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
toggleCommandPalette();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 01:18:57 +01:00
|
|
|
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
2026-02-05 01:59:49 +02:00
|
|
|
void showOpenCodeStatus();
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 02:27:10 +02:00
|
|
|
if (hasModifier(e) && e.key === '.') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
toggleHelpDialog();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 01:18:57 +01:00
|
|
|
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
2026-01-07 23:37:32 +02:00
|
|
|
|
2026-01-16 00:26:25 +02:00
|
|
|
const isVSCode = isVSCodeRuntime();
|
2026-01-07 23:37:32 +02:00
|
|
|
const autoWorktree = useConfigStore.getState().settingsAutoCreateWorktree;
|
|
|
|
|
// If autoWorktree is true: Cmd+N -> Worktree, Cmd+Shift+N -> Standard
|
|
|
|
|
// If autoWorktree is false: Cmd+N -> Standard, Cmd+Shift+N -> Worktree
|
2026-01-16 00:26:25 +02:00
|
|
|
// VS Code: always open standard session (no worktree support)
|
|
|
|
|
const shouldCreateWorktree = isVSCode ? false : (autoWorktree ? !e.shiftKey : e.shiftKey);
|
2026-01-07 23:37:32 +02:00
|
|
|
|
|
|
|
|
if (shouldCreateWorktree) {
|
|
|
|
|
// Create new session with auto-generated worktree
|
2026-01-07 22:06:28 +02:00
|
|
|
setActiveMainTab('chat');
|
|
|
|
|
setSessionSwitcherOpen(false);
|
|
|
|
|
createWorktreeSession();
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-07 23:37:32 +02:00
|
|
|
// Open a new session without worktree
|
2025-12-21 20:18:51 +02:00
|
|
|
setActiveMainTab('chat');
|
|
|
|
|
setSessionSwitcherOpen(false);
|
|
|
|
|
openNewSessionDraft();
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 01:18:57 +01:00
|
|
|
if (hasModifier(e) && e.key === '/') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
|
|
|
|
const currentIndex = modes.indexOf(themeMode);
|
|
|
|
|
const nextIndex = (currentIndex + 1) % modes.length;
|
|
|
|
|
setThemeMode(modes[nextIndex]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 14:45:46 +02:00
|
|
|
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
2026-01-03 14:45:46 +02:00
|
|
|
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
|
|
|
|
|
setTimelineDialogOpen(!isTimelineDialogOpen);
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 01:18:57 +01:00
|
|
|
if (hasModifier(e) && !e.shiftKey && e.key === ',') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
const { isSettingsDialogOpen } = useUIStore.getState();
|
|
|
|
|
setSettingsDialogOpen(!isSettingsDialogOpen);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 01:18:57 +01:00
|
|
|
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'l') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
setSessionSwitcherOpen(!isSessionSwitcherOpen);
|
|
|
|
|
} else {
|
|
|
|
|
toggleSidebar();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 01:18:57 +01:00
|
|
|
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'i') {
|
2025-12-07 19:32:53 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
|
|
|
|
textarea?.focus();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 02:27:10 +02:00
|
|
|
// 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') {
|
2026-01-02 13:08:14 +02:00
|
|
|
const {
|
|
|
|
|
isSettingsDialogOpen,
|
|
|
|
|
isCommandPaletteOpen,
|
|
|
|
|
isHelpDialogOpen,
|
|
|
|
|
isSessionSwitcherOpen,
|
|
|
|
|
isAboutDialogOpen,
|
|
|
|
|
activeMainTab,
|
|
|
|
|
isModelSelectorOpen,
|
|
|
|
|
} = useUIStore.getState();
|
|
|
|
|
|
|
|
|
|
// Skip if settings open
|
|
|
|
|
if (isSettingsDialogOpen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip if any overlay open or not on chat tab
|
2026-01-07 22:06:28 +02:00
|
|
|
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
|
2026-01-02 13:08:14 +02:00
|
|
|
const isChatActive = activeMainTab === 'chat';
|
|
|
|
|
|
|
|
|
|
if (hasOverlay || !isChatActive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setModelSelectorOpen(!isModelSelectorOpen);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 17:20:33 +02:00
|
|
|
// Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M)
|
|
|
|
|
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 't') {
|
|
|
|
|
const {
|
|
|
|
|
isSettingsDialogOpen,
|
|
|
|
|
isCommandPaletteOpen,
|
|
|
|
|
isHelpDialogOpen,
|
|
|
|
|
isSessionSwitcherOpen,
|
|
|
|
|
isAboutDialogOpen,
|
|
|
|
|
activeMainTab,
|
|
|
|
|
} = useUIStore.getState();
|
|
|
|
|
|
|
|
|
|
if (isSettingsDialogOpen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
|
|
|
|
|
const isChatActive = activeMainTab === 'chat';
|
|
|
|
|
|
|
|
|
|
if (hasOverlay || !isChatActive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const configState = useConfigStore.getState();
|
|
|
|
|
const variants = configState.getCurrentModelVariants();
|
|
|
|
|
if (variants.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
configState.cycleCurrentVariant();
|
|
|
|
|
|
|
|
|
|
const nextVariant = useConfigStore.getState().currentVariant;
|
|
|
|
|
const sessionState = useSessionStore.getState();
|
|
|
|
|
const sessionId = sessionState.currentSessionId;
|
|
|
|
|
const agentName = useConfigStore.getState().currentAgentName;
|
|
|
|
|
const providerId = useConfigStore.getState().currentProviderId;
|
|
|
|
|
const modelId = useConfigStore.getState().currentModelId;
|
|
|
|
|
|
|
|
|
|
if (sessionId && agentName && providerId && modelId) {
|
|
|
|
|
sessionState.saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
if (e.key === 'Escape') {
|
2026-02-10 02:35:56 +02:00
|
|
|
const target = e.target as Element | null;
|
|
|
|
|
const isInsideDialog = Boolean(target?.closest('[role="dialog"]'));
|
|
|
|
|
const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]'));
|
|
|
|
|
|
|
|
|
|
if (isInsideDialog || isSettingsMounted) {
|
|
|
|
|
resetAbortPriming();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 02:17:09 +02:00
|
|
|
const {
|
|
|
|
|
isSettingsDialogOpen,
|
|
|
|
|
isCommandPaletteOpen,
|
|
|
|
|
isHelpDialogOpen,
|
|
|
|
|
isSessionSwitcherOpen,
|
|
|
|
|
isAboutDialogOpen,
|
2026-01-30 02:46:43 +02:00
|
|
|
isMultiRunLauncherOpen,
|
|
|
|
|
isImagePreviewOpen,
|
2025-12-28 02:17:09 +02:00
|
|
|
activeMainTab,
|
|
|
|
|
} = useUIStore.getState();
|
|
|
|
|
|
|
|
|
|
// If settings is open, close it
|
|
|
|
|
if (isSettingsDialogOpen) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSettingsDialogOpen(false);
|
|
|
|
|
resetAbortPriming();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if any overlay is open or not on chat tab - don't process abort
|
2026-01-30 02:46:43 +02:00
|
|
|
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen;
|
2025-12-28 02:17:09 +02:00
|
|
|
const isChatActive = activeMainTab === 'chat';
|
|
|
|
|
|
|
|
|
|
if (hasOverlay || !isChatActive) {
|
|
|
|
|
resetAbortPriming();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Double-ESC abort logic - only when on chat tab with no overlays
|
2025-12-07 19:32:53 +02:00
|
|
|
const sessionId = currentSessionId;
|
|
|
|
|
const canAbortNow = working.canAbort && Boolean(sessionId);
|
|
|
|
|
if (!canAbortNow) {
|
|
|
|
|
resetAbortPriming();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const primedUntil = abortPrimedUntilRef.current;
|
|
|
|
|
|
|
|
|
|
if (primedUntil && now < primedUntil) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
resetAbortPriming();
|
|
|
|
|
void abortCurrentOperation();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const expiresAt = armAbortPrompt(3000) ?? now + 3000;
|
|
|
|
|
abortPrimedUntilRef.current = expiresAt;
|
|
|
|
|
|
|
|
|
|
if (abortPrimedTimeoutRef.current) {
|
|
|
|
|
clearTimeout(abortPrimedTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const delay = Math.max(expiresAt - now, 0);
|
|
|
|
|
abortPrimedTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
if (abortPrimedUntilRef.current && Date.now() >= abortPrimedUntilRef.current) {
|
|
|
|
|
resetAbortPriming();
|
|
|
|
|
}
|
|
|
|
|
}, delay || 0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
};
|
|
|
|
|
}, [
|
2025-12-21 20:18:51 +02:00
|
|
|
openNewSessionDraft,
|
2025-12-07 19:32:53 +02:00
|
|
|
abortCurrentOperation,
|
|
|
|
|
toggleCommandPalette,
|
|
|
|
|
toggleHelpDialog,
|
|
|
|
|
toggleSidebar,
|
|
|
|
|
setSessionSwitcherOpen,
|
|
|
|
|
setActiveMainTab,
|
|
|
|
|
setSettingsDialogOpen,
|
2026-01-02 13:08:14 +02:00
|
|
|
setModelSelectorOpen,
|
2025-12-07 19:32:53 +02:00
|
|
|
setThemeMode,
|
|
|
|
|
themeMode,
|
|
|
|
|
working,
|
|
|
|
|
armAbortPrompt,
|
|
|
|
|
resetAbortPriming,
|
|
|
|
|
currentSessionId,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
resetAbortPriming();
|
|
|
|
|
};
|
|
|
|
|
}, [resetAbortPriming]);
|
|
|
|
|
};
|