Files
openchamber/packages/ui/src/hooks/useKeyboardShortcuts.ts
T

297 lines
9.5 KiB
TypeScript
Raw Normal View History

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';
import { hasModifier } from '@/lib/utils';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useConfigStore } from '@/stores/useConfigStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
2025-12-07 19:32:53 +02:00
export const useKeyboardShortcuts = () => {
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
2025-12-07 19:32:53 +02:00
const {
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setActiveMainTab,
setSettingsDialogOpen,
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) => {
if (hasModifier(e) && e.key === 'k') {
2025-12-07 19:32:53 +02:00
e.preventDefault();
toggleCommandPalette();
}
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
2025-12-07 19:32:53 +02:00
e.preventDefault();
void showOpenCodeStatus();
2025-12-07 19:32:53 +02:00
return;
}
if (hasModifier(e) && e.key === '.') {
2025-12-07 19:32:53 +02:00
e.preventDefault();
toggleHelpDialog();
}
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
2025-12-07 19:32:53 +02:00
e.preventDefault();
const isVSCode = isVSCodeRuntime();
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
// VS Code: always open standard session (no worktree support)
const shouldCreateWorktree = isVSCode ? false : (autoWorktree ? !e.shiftKey : e.shiftKey);
if (shouldCreateWorktree) {
// Create new session with auto-generated worktree
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
createWorktreeSession();
2025-12-07 19:32:53 +02:00
return;
}
// Open a new session without worktree
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
2025-12-07 19:32:53 +02: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]);
}
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
2025-12-07 19:32:53 +02:00
e.preventDefault();
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
setTimelineDialogOpen(!isTimelineDialogOpen);
2025-12-07 19:32:53 +02:00
return;
}
if (hasModifier(e) && !e.shiftKey && e.key === ',') {
2025-12-07 19:32:53 +02:00
e.preventDefault();
const { isSettingsDialogOpen } = useUIStore.getState();
setSettingsDialogOpen(!isSettingsDialogOpen);
return;
}
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;
}
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;
}
// 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') {
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
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
if (hasOverlay || !isChatActive) {
return;
}
e.preventDefault();
setModelSelectorOpen(!isModelSelectorOpen);
return;
}
// 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') {
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;
}
const {
isSettingsDialogOpen,
isCommandPaletteOpen,
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
isMultiRunLauncherOpen,
isImagePreviewOpen,
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
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen;
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);
};
}, [
openNewSessionDraft,
2025-12-07 19:32:53 +02:00
abortCurrentOperation,
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setActiveMainTab,
setSettingsDialogOpen,
setModelSelectorOpen,
2025-12-07 19:32:53 +02:00
setThemeMode,
themeMode,
working,
armAbortPrompt,
resetAbortPriming,
currentSessionId,
]);
React.useEffect(() => {
return () => {
resetAbortPriming();
};
}, [resetAbortPriming]);
};