From e361606c61d8fbcc9a1f17d3674e3a0421aa28ee Mon Sep 17 00:00:00 2001 From: silentoplayz Date: Sat, 11 Oct 2025 20:59:08 -0400 Subject: [PATCH] feat: add toggleable hotkey hints to sidebar buttons and refac ShortcutsModal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/lib/shortcuts.ts as the single source of truth for every shortcut - Create HotkeyHint.svelte to show OS-aware key combos (⌘ on Mac, Ctrl elsewhere) - Make sidebar “New Chat” and “Search” buttons display their shortcuts on hover - Add user setting to toggle these sidebar hints - Refactor ShortcutsModal into categorized (Global, Chat, Message, Input), data-driven sections - Introduce ShortcutItem.svelte to render each row, dividers, and multi-line text - Fix “Focus text area” action and include “Close modal” shortcut - Wire everything through +layout.svelte and the shortcuts registry --- .../components/chat/Settings/Interface.svelte | 21 + src/lib/components/chat/ShortcutItem.svelte | 58 +++ src/lib/components/chat/ShortcutsModal.svelte | 417 +++--------------- src/lib/components/common/HotkeyHint.svelte | 41 ++ src/lib/components/layout/Sidebar.svelte | 12 +- src/lib/i18n/locales/en-US/translation.json | 1 + src/lib/shortcuts.ts | 146 ++++++ src/lib/stores/index.ts | 1 + src/routes/(app)/+layout.svelte | 206 +++++---- 9 files changed, 450 insertions(+), 453 deletions(-) create mode 100644 src/lib/components/chat/ShortcutItem.svelte create mode 100644 src/lib/components/common/HotkeyHint.svelte create mode 100644 src/lib/shortcuts.ts diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 4ae24d0846..adbf8f05b1 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -67,6 +67,7 @@ let collapseCodeBlocks = false; let expandDetails = false; let showChatTitleInTab = true; + let showSidebarHotkeyHints = true; let showFloatingActionButtons = true; let floatingActionButtons = null; @@ -226,6 +227,7 @@ chatDirection = $settings?.chatDirection ?? 'auto'; userLocation = $settings?.userLocation ?? false; showChatTitleInTab = $settings?.showChatTitleInTab ?? true; + showSidebarHotkeyHints = $settings?.showSidebarHotkeyHints ?? true; notificationSound = $settings?.notificationSound ?? true; notificationSoundAlways = $settings?.notificationSoundAlways ?? false; @@ -350,6 +352,25 @@ +
+
+
+ {$i18n.t('Show Sidebar Hotkey Hints')} +
+ +
+ { + saveSettings({ showSidebarHotkeyHints }); + }} + /> +
+
+
+
diff --git a/src/lib/components/chat/ShortcutItem.svelte b/src/lib/components/chat/ShortcutItem.svelte new file mode 100644 index 0000000000..6eca81d3bd --- /dev/null +++ b/src/lib/components/chat/ShortcutItem.svelte @@ -0,0 +1,58 @@ + + +
+
+ {#if shortcut.tooltip} + + + {$i18n.t(shortcut.name)} * + + + {:else} + {$i18n.t(shortcut.name)} + {/if} +
+
+ {#each shortcut.keys as key} +
+ {formatKey(key)} +
+ {/each} +
+
\ No newline at end of file diff --git a/src/lib/components/chat/ShortcutsModal.svelte b/src/lib/components/chat/ShortcutsModal.svelte index d5b083caa5..12161e75f5 100644 --- a/src/lib/components/chat/ShortcutsModal.svelte +++ b/src/lib/components/chat/ShortcutsModal.svelte @@ -1,392 +1,107 @@
-
-
{$i18n.t('Keyboard shortcuts')}
-
-
-
-
-
-
{$i18n.t('Open new chat')}
- -
-
- Ctrl/⌘ -
- -
- Shift -
- -
- O -
-
-
- -
-
{$i18n.t('Focus chat input')}
- -
-
- Shift -
- -
- Esc -
-
-
- -
-
- - {$i18n.t('Stop Generating')} * - -
- -
-
- Esc -
-
-
- -
-
{$i18n.t('Copy last code block')}
- -
-
- Ctrl/⌘ -
- -
- Shift -
- -
- ; -
-
-
- -
-
{$i18n.t('Copy last response')}
- -
-
- Ctrl/⌘ -
- -
- Shift -
- -
- C -
-
-
- -
-
- - {$i18n.t('Prevent file creation')} * - -
- -
-
- Ctrl/⌘ -
- -
- Shift -
- -
- V -
-
-
+ {#each Object.entries(categorizedShortcuts) as [category, columns], i} + {#if i > 0} +
+
+ {/if} -
-
-
{$i18n.t('Generate prompt pair')}
- -
-
- Ctrl/⌘ -
- -
- Shift -
- -
- Enter -
-
+
+
{$i18n.t(category)}
+
+
+
+
+ {#each columns.left as shortcut} + + {/each}
- -
-
{$i18n.t('Toggle search')}
- -
-
- Ctrl/⌘ -
-
- K -
-
-
- -
-
{$i18n.t('Toggle settings')}
- -
-
- Ctrl/⌘ -
-
- . -
-
-
- -
-
{$i18n.t('Toggle sidebar')}
- -
-
- Ctrl/⌘ -
- -
- Shift -
- -
- S -
-
-
- -
-
{$i18n.t('Delete chat')}
- -
-
- Ctrl/⌘ -
-
- Shift -
- -
- ⌫/Delete -
-
-
- -
-
{$i18n.t('Show shortcuts')}
- -
-
- Ctrl/⌘ -
- -
- / -
-
+
+ {#each columns.right as shortcut} + + {/each}
-
+ {/each}
- {$i18n.t( + {@html $i18n.t( 'Shortcuts with an asterisk (*) are situational and only active under specific conditions.' )}
-
-
{$i18n.t('Input commands')}
-
- -
-
-
-
-
- {$i18n.t('Attach file from knowledge')} -
- -
-
- # -
-
-
- -
-
- {$i18n.t('Add custom prompt')} -
- -
-
- / -
-
-
- -
-
- {$i18n.t('Talk to model')} -
- -
-
- @ -
-
-
- -
-
- {$i18n.t('Accept autocomplete generation / Jump to prompt variable')} -
- -
-
- TAB -
-
-
-
-
-
+ \ No newline at end of file diff --git a/src/lib/components/common/HotkeyHint.svelte b/src/lib/components/common/HotkeyHint.svelte new file mode 100644 index 0000000000..1246630cbd --- /dev/null +++ b/src/lib/components/common/HotkeyHint.svelte @@ -0,0 +1,41 @@ + + +{#if mounted && isVisible} + +{/if} \ No newline at end of file diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 282909465e..dd0eb0ec81 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -62,6 +62,7 @@ import PinnedModelList from './Sidebar/PinnedModelList.svelte'; import Note from '../icons/Note.svelte'; import { slide } from 'svelte/transition'; + import HotkeyHint from '../common/HotkeyHint.svelte'; const BREAKPOINT = 768; @@ -787,7 +788,7 @@ -
-
+
{$i18n.t('Search')}
+
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 0ed731795f..5977953945 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -1446,6 +1446,7 @@ "Show image preview": "", "Show Model": "", "Show shortcuts": "", + "Show Sidebar Hotkey Hints": "", "Show your support!": "", "Showcased creativity": "", "Sign in": "", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts new file mode 100644 index 0000000000..44eee95d6c --- /dev/null +++ b/src/lib/shortcuts.ts @@ -0,0 +1,146 @@ +type ShortcutRegistry = { + [key in Shortcut]: { + name: string; + keys: string[]; + category: string; + tooltip?: string; + }; +}; + +export enum Shortcut { + //Chat + NEW_CHAT = 'newChat', + NEW_TEMPORARY_CHAT = 'newTemporaryChat', + DELETE_CHAT = 'deleteChat', + GENERATE_PROMPT_PAIR = 'generatePromptPair', + + //Global + SEARCH = 'search', + OPEN_SETTINGS = 'openSettings', + SHOW_SHORTCUTS = 'showShortcuts', + TOGGLE_SIDEBAR = 'toggleSidebar', + CLOSE_MODAL = 'closeModal', + + //Input + FOCUS_INPUT = 'focusInput', + ACCEPT_AUTOCOMPLETE = 'acceptAutocomplete', + PREVENT_FILE_CREATION = 'preventFileCreation', + NAVIGATE_PROMPT_HISTORY_UP = 'navigatePromptHistoryUp', + ATTACH_FILE = 'attachFile', + ADD_PROMPT = 'addPrompt', + TALK_TO_MODEL = 'talkToModel', + + //Message + COPY_LAST_CODE_BLOCK = 'copyLastCodeBlock', + COPY_LAST_RESPONSE = 'copyLastResponse', + STOP_GENERATING = 'stopGenerating' +} + +export const shortcuts: ShortcutRegistry = { + //Chat + [Shortcut.NEW_CHAT]: { + name: 'New Chat', + keys: ['mod', 'shift', 'KeyO'], + category: 'Chat' + }, + [Shortcut.NEW_TEMPORARY_CHAT]: { + name: 'New Temporary Chat', + keys: ['mod', 'shift', 'Quote'], + category: 'Chat' + }, + [Shortcut.DELETE_CHAT]: { + name: 'Delete Chat', + keys: ['mod', 'shift', 'Backspace'], + category: 'Chat' + }, + [Shortcut.GENERATE_PROMPT_PAIR]: { + name: 'Generate Prompt Pair', + keys: ['mod', 'shift', 'Enter'], + category: 'Chat', + tooltip: 'Only active when the chat input is in focus and an LLM is generating a response.' + }, + + //Global + [Shortcut.SEARCH]: { + name: 'Search', + keys: ['mod', 'KeyK'], + category: 'Global' + }, + [Shortcut.OPEN_SETTINGS]: { + name: 'Open Settings', + keys: ['mod', 'Period'], + category: 'Global' + }, + [Shortcut.SHOW_SHORTCUTS]: { + name: 'Show Shortcuts', + keys: ['mod', 'Slash'], + category: 'Global' + }, + [Shortcut.TOGGLE_SIDEBAR]: { + name: 'Toggle Sidebar', + keys: ['mod', 'shift', 'KeyS'], + category: 'Global' + }, + [Shortcut.CLOSE_MODAL]: { + name: 'Close Modal', + keys: ['Escape'], + category: 'Global' + }, + + //Input + [Shortcut.FOCUS_INPUT]: { + name: 'Focus Text Area', + keys: ['shift', 'Escape'], + category: 'Input' + }, + [Shortcut.ACCEPT_AUTOCOMPLETE]: { + name: 'Accept Autocomplete Generation\nJump to Prompt Variable', + keys: ['Tab'], + category: 'Input' + }, + [Shortcut.PREVENT_FILE_CREATION]: { + name: 'Prevent File Creation', + keys: ['mod', 'shift', 'KeyV'], + category: 'Input', + tooltip: 'Only active when "Paste Large Text as File" setting is toggled on.' + }, + [Shortcut.NAVIGATE_PROMPT_HISTORY_UP]: { + name: 'Edit Last Message', + keys: ['ArrowUp'], + category: 'Input', + tooltip: 'Only can be triggered when the chat input is in focus.' + }, + [Shortcut.ATTACH_FILE]: { + name: 'Attach File From Knowledge', + keys: ['#'], + category: 'Input' + }, + [Shortcut.ADD_PROMPT]: { + name: 'Add Custom Prompt', + keys: ['/'], + category: 'Input' + }, + [Shortcut.TALK_TO_MODEL]: { + name: 'Talk to Model', + keys: ['@'], + category: 'Input' + }, + + //Message + [Shortcut.COPY_LAST_CODE_BLOCK]: { + name: 'Copy Last Code Block', + keys: ['mod', 'shift', 'Semicolon'], + category: 'Message' + }, + [Shortcut.COPY_LAST_RESPONSE]: { + name: 'Copy Last Response', + keys: ['mod', 'shift', 'KeyC'], + category: 'Message' + }, + [Shortcut.STOP_GENERATING]: { + name: 'Stop Generating', + keys: ['Escape'], + category: 'Message', + tooltip: 'Only active when the chat input is in focus and an LLM is generating a response.' + } +}; \ No newline at end of file diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index de37963adb..9fab67df4b 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -187,6 +187,7 @@ type Settings = { highContrastMode?: boolean; title?: TitleSettings; showChatTitleInTab?: boolean; + showSidebarHotkeyHints?: boolean; splitLargeDeltas?: boolean; chatDirection?: 'LTR' | 'RTL' | 'auto'; ctrlEnterToSend?: boolean; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index f9a8470465..979a9ca3ca 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -47,6 +47,7 @@ import AccountPending from '$lib/components/layout/Overlay/AccountPending.svelte'; import UpdateInfoToast from '$lib/components/layout/UpdateInfoToast.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; + import { Shortcut, shortcuts } from '$lib/shortcuts'; const i18n = getContext('i18n'); @@ -163,105 +164,114 @@ }) ]); - const setupKeyboardShortcuts = () => { - document.addEventListener('keydown', async function (event) { - const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac - // Check if the Shift key is pressed - const isShiftPressed = event.shiftKey; +// Helper function to check if the pressed keys match the shortcut definition + const checkShortcut = (event: KeyboardEvent, keys: string[]): boolean => { + const lowerCaseKeys = keys.map((key) => key.toLowerCase()); - // Check if Ctrl + K is pressed - if (isCtrlPressed && event.key.toLowerCase() === 'k') { - event.preventDefault(); - console.log('search'); - showSearch.set(!$showSearch); + const isModPressed = lowerCaseKeys.includes('mod') ? event.ctrlKey || event.metaKey : true; + const isShiftPressed = lowerCaseKeys.includes('shift') ? event.shiftKey : true; + const isAltPressed = lowerCaseKeys.includes('alt') ? event.altKey : true; + const isCtrlPressed = lowerCaseKeys.includes('ctrl') ? event.ctrlKey : true; + + const mainKeys = lowerCaseKeys.filter((key) => !['mod', 'shift', 'alt', 'ctrl'].includes(key)); + + if (mainKeys.length > 0 && !mainKeys.includes(event.code.toLowerCase())) { + return false; + } + + let modConflict = false; + if (keys.includes('mod')) { + if (!lowerCaseKeys.includes('shift') && event.shiftKey) modConflict = true; + if (!lowerCaseKeys.includes('alt') && event.altKey) modConflict = true; + } + + return ( + !modConflict && + isModPressed && + isShiftPressed && + isAltPressed && + isCtrlPressed && + (event.ctrlKey || event.metaKey) === (lowerCaseKeys.includes('mod') || lowerCaseKeys.includes('ctrl')) && + event.shiftKey === lowerCaseKeys.includes('shift') && + event.altKey === lowerCaseKeys.includes('alt') + ); + }; + +const setupKeyboardShortcuts = () => { + document.addEventListener('keydown', async (event) => { + for (const shortcutName in shortcuts) { + const shortcut = shortcuts[shortcutName]; + if (checkShortcut(event, shortcut.keys)) { + // Here you can map the shortcut name to an action + // This is a simple example, you might want a more robust solution + switch (shortcutName) { + case Shortcut.SEARCH: + event.preventDefault(); + showSearch.set(!$showSearch); + break; + case Shortcut.NEW_CHAT: + event.preventDefault(); + document.getElementById('sidebar-new-chat-button')?.click(); + break; + case Shortcut.FOCUS_INPUT: + event.preventDefault(); + document.getElementById('chat-input')?.focus(); + break; + case Shortcut.COPY_LAST_CODE_BLOCK: + event.preventDefault(); + [...document.getElementsByClassName('copy-code-button')]?.at(-1)?.click(); + break; + case Shortcut.COPY_LAST_RESPONSE: + event.preventDefault(); + [...document.getElementsByClassName('copy-response-button')]?.at(-1)?.click(); + break; + case Shortcut.TOGGLE_SIDEBAR: + event.preventDefault(); + showSidebar.set(!$showSidebar); + break; + case Shortcut.DELETE_CHAT: + event.preventDefault(); + document.getElementById('delete-chat-button')?.click(); + break; + case Shortcut.OPEN_SETTINGS: + event.preventDefault(); + showSettings.set(!$showSettings); + break; + case Shortcut.SHOW_SHORTCUTS: + event.preventDefault(); + showShortcuts.set(!$showShortcuts); + break; + case Shortcut.CLOSE_MODAL: + event.preventDefault(); + showSettings.set(false); + showShortcuts.set(false); + break; + case Shortcut.NEW_TEMPORARY_CHAT: + event.preventDefault(); + if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) { + temporaryChatEnabled.set(true); + } else { + temporaryChatEnabled.set(!$temporaryChatEnabled); + } + await goto('/'); + setTimeout(() => { + document.getElementById('new-chat-button')?.click(); + }, 0); + break; + case Shortcut.GENERATE_PROMPT_PAIR: + // Placeholder for future implementation + break; + case Shortcut.STOP_GENERATING: + // Placeholder for future implementation + break; + case Shortcut.PREVENT_FILE_CREATION: + // This shortcut is handled by the paste event in MessageInput.svelte + break; } - - // Check if Ctrl + Shift + O is pressed - if (isCtrlPressed && isShiftPressed && event.key.toLowerCase() === 'o') { - event.preventDefault(); - console.log('newChat'); - document.getElementById('sidebar-new-chat-button')?.click(); - } - - // Check if Shift + Esc is pressed - if (isShiftPressed && event.key === 'Escape') { - event.preventDefault(); - console.log('focusInput'); - document.getElementById('chat-input')?.focus(); - } - - // Check if Ctrl + Shift + ; is pressed - if (isCtrlPressed && isShiftPressed && event.key === ';') { - event.preventDefault(); - console.log('copyLastCodeBlock'); - const button = [...document.getElementsByClassName('copy-code-button')]?.at(-1); - button?.click(); - } - - // Check if Ctrl + Shift + C is pressed - if (isCtrlPressed && isShiftPressed && event.key.toLowerCase() === 'c') { - event.preventDefault(); - console.log('copyLastResponse'); - const button = [...document.getElementsByClassName('copy-response-button')]?.at(-1); - console.log(button); - button?.click(); - } - - // Check if Ctrl + Shift + S is pressed - if (isCtrlPressed && isShiftPressed && event.key.toLowerCase() === 's') { - event.preventDefault(); - console.log('toggleSidebar'); - document.getElementById('sidebar-toggle-button')?.click(); - } - - // Check if Ctrl + Shift + Backspace is pressed - if ( - isCtrlPressed && - isShiftPressed && - (event.key === 'Backspace' || event.key === 'Delete') - ) { - event.preventDefault(); - console.log('deleteChat'); - document.getElementById('delete-chat-button')?.click(); - } - - // Check if Ctrl + . is pressed - if (isCtrlPressed && event.key === '.') { - event.preventDefault(); - console.log('openSettings'); - showSettings.set(!$showSettings); - } - - // Check if Ctrl + / is pressed - if (isCtrlPressed && event.key === '/') { - event.preventDefault(); - - showShortcuts.set(!$showShortcuts); - } - - // Check if Ctrl + Shift + ' is pressed - if ( - isCtrlPressed && - isShiftPressed && - (event.key.toLowerCase() === `'` || event.key.toLowerCase() === `"`) - ) { - event.preventDefault(); - console.log('temporaryChat'); - - if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) { - temporaryChatEnabled.set(true); - } else { - temporaryChatEnabled.set(!$temporaryChatEnabled); - } - - await goto('/'); - const newChatButton = document.getElementById('new-chat-button'); - setTimeout(() => { - newChatButton?.click(); - }, 0); - } - }); - }; + } + } + }); +}; setupKeyboardShortcuts(); if ($user?.role === 'admin' && ($settings?.showChangelog ?? true)) { @@ -445,4 +455,4 @@ cursor: pointer; background-color: #bcbabb; } - + \ No newline at end of file