feat: add toggleable hotkey hints to sidebar buttons and refac ShortcutsModal

- 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
This commit is contained in:
silentoplayz 2025-10-11 20:59:08 -04:00
parent e24fec0de4
commit e361606c61
9 changed files with 450 additions and 453 deletions

View file

@ -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 @@
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="show-sidebar-hotkey-hints-label" class=" self-center text-xs">
{$i18n.t('Show Sidebar Hotkey Hints')}
</div>
<div class="flex items-center gap-2 p-1">
<Switch
ariaLabelledbyId="show-sidebar-hotkey-hints-label"
tooltip={true}
bind:state={showSidebarHotkeyHints}
on:change={() => {
saveSettings({ showSidebarHotkeyHints });
}}
/>
</div>
</div>
</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div id="notification-sound-label" class=" self-center text-xs">

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { getContext } from 'svelte';
import Tooltip from '../common/Tooltip.svelte';
import type { Shortcut } from '$lib/shortcuts';
export let shortcut: Shortcut;
export let isMac: boolean;
const i18n = getContext('i18n');
function formatKey(key: string): string {
const lowerKey = key.toLowerCase();
if (lowerKey === 'mod') return isMac ? '⌘' : 'Ctrl/⌘';
if (lowerKey === 'shift') return isMac ? '⇧' : 'Shift';
if (lowerKey === 'backspace') return '⌫/Delete';
if (lowerKey === 'escape') return 'Esc';
if (lowerKey === 'enter') return 'Enter';
if (lowerKey === 'tab') return 'Tab';
if (lowerKey === 'arrowup') return '↑';
if (lowerKey === 'arrowdown') return '↓';
// For keys like 'KeyK', 'KeyO', etc., we just want the last character.
if (lowerKey.startsWith('key')) return key.slice(-1);
// For keys like 'Digit2', 'Digit3', etc.
if (lowerKey.startsWith('digit')) return key.slice(-1);
// For other special keys
if (lowerKey === 'quote') return "'";
if (lowerKey === 'period') return '.';
if (lowerKey === 'slash') return '/';
if (lowerKey === 'semicolon') return ';';
return key.toUpperCase();
}
</script>
<div class="w-full flex justify-between items-center">
<div class="text-sm whitespace-pre-line">
{#if shortcut.tooltip}
<Tooltip content={$i18n.t(shortcut.tooltip)}>
<span class="whitespace-nowrap">
{$i18n.t(shortcut.name)}<span class="text-xs">&nbsp;*</span>
</span>
</Tooltip>
{:else}
{$i18n.t(shortcut.name)}
{/if}
</div>
<div class="flex-shrink-0 flex items-center self-center space-x-1 text-xs">
{#each shortcut.keys as key}
<div
class="h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
{formatKey(key)}
</div>
{/each}
</div>
</div>

View file

@ -1,392 +1,107 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, onMount } from 'svelte';
import Modal from '../common/Modal.svelte';
import Tooltip from '../common/Tooltip.svelte';
const i18n = getContext('i18n');
import { shortcuts } from '$lib/shortcuts';
import ShortcutItem from './ShortcutItem.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
type CategorizedShortcuts = {
[category: string]: {
left: Shortcut[];
right: Shortcut[];
};
};
const i18n = getContext('i18n');
export let show = false;
let categorizedShortcuts: CategorizedShortcuts = {};
let isMac = false;
onMount(() => {
isMac = /Mac/i.test(navigator.userAgent);
const allShortcuts = Object.values(shortcuts);
const result = allShortcuts.reduce((acc, shortcut) => {
const category = shortcut.category;
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(shortcut);
return acc;
}, {});
for (const category in result) {
const half = Math.ceil(result[category].length / 2);
categorizedShortcuts[category] = {
left: result[category].slice(0, half),
right: result[category].slice(half)
};
}
});
</script>
<Modal bind:show>
<div class="text-gray-700 dark:text-gray-100">
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4">
<div class=" text-lg font-medium self-center">{$i18n.t('Keyboard shortcuts')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4">
<div class="text-lg font-medium self-center">{$i18n.t('Shortcuts')}</div>
<button class="self-center" on:click={() => (show = false)}>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<div class="flex flex-col space-y-3 w-full self-start">
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Open new chat')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
O
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Focus chat input')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Esc
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">
<Tooltip
content={$i18n.t(
'Only active when the chat input is in focus and an LLM is generating a response.'
)}
>
{$i18n.t('Stop Generating')}<span class="text-xs"> *</span>
</Tooltip>
</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Esc
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Copy last code block')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
;
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Copy last response')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
C
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">
<Tooltip
content={$i18n.t(
'Only active when "Paste Large Text as File" setting is toggled on.'
)}
>
{$i18n.t('Prevent file creation')}<span class="text-s"> *</span>
</Tooltip>
</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
V
</div>
</div>
</div>
{#each Object.entries(categorizedShortcuts) as [category, columns], i}
{#if i > 0}
<div class="px-5">
<div class="w-full border-t dark:border-gray-700 border-gray-200" />
</div>
{/if}
<div class="flex flex-col space-y-3 w-full self-start">
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Generate prompt pair')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Enter
</div>
</div>
<div class="flex justify-between dark:text-gray-300 px-5 pt-4">
<div class="text-lg font-medium self-center">{$i18n.t(category)}</div>
</div>
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<div class="flex flex-col space-y-3 w-full self-start">
{#each columns.left as shortcut}
<ShortcutItem {shortcut} {isMac} />
{/each}
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Toggle search')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
K
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Toggle settings')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
.
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Toggle sidebar')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
S
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Delete chat')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Shift
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
⌫/Delete
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">{$i18n.t('Show shortcuts')}</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
Ctrl/⌘
</div>
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
/
</div>
</div>
<div class="flex flex-col space-y-3 w-full self-start">
{#each columns.right as shortcut}
<ShortcutItem {shortcut} {isMac} />
{/each}
</div>
</div>
</div>
</div>
{/each}
<div class="px-5 pb-4 text-xs text-gray-500 dark:text-gray-400">
{$i18n.t(
{@html $i18n.t(
'Shortcuts with an asterisk (*) are situational and only active under specific conditions.'
)}
</div>
<div class=" flex justify-between dark:text-gray-300 px-5">
<div class=" text-lg font-medium self-center">{$i18n.t('Input commands')}</div>
</div>
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<div class="flex flex-col space-y-3 w-full self-start">
<div class="w-full flex justify-between items-center">
<div class=" text-sm">
{$i18n.t('Attach file from knowledge')}
</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
#
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">
{$i18n.t('Add custom prompt')}
</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
/
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">
{$i18n.t('Talk to model')}
</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
@
</div>
</div>
</div>
<div class="w-full flex justify-between items-center">
<div class=" text-sm">
{$i18n.t('Accept autocomplete generation / Jump to prompt variable')}
</div>
<div class="flex space-x-1 text-xs">
<div
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
>
TAB
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Modal>
<style>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
/* display: none; <- Crashes Chrome on hover */
-webkit-appearance: none;
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
margin: 0;
}
.tabs::-webkit-scrollbar {
display: none; /* for Chrome, Safari and Opera */
display: none;
}
.tabs {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
}
input[type='number'] {
-moz-appearance: textfield; /* Firefox */
-moz-appearance: textfield;
}
</style>
</style>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { onMount } from 'svelte';
import { shortcuts } from '$lib/shortcuts';
import { settings } from '$lib/stores';
export let name: string;
export let className = '';
let isMac = false;
let mounted = false;
let keys: string[] = [];
let isVisible = true;
onMount(() => {
isMac = /Mac/i.test(navigator.userAgent);
keys = shortcuts[name]?.keys ?? [];
mounted = true;
});
$: if (name === 'newChat' || name === 'search') {
isVisible = $settings?.showSidebarHotkeyHints !== false;
}
function formatKey(key: string): string {
const lowerKey = key.toLowerCase();
if (lowerKey === 'mod') return isMac ? '⌘' : 'Ctrl';
if (lowerKey === 'shift') return isMac ? '⇧' : 'Shift';
if (lowerKey.startsWith('key')) return key.slice(-1);
return key;
}
</script>
{#if mounted && isVisible}
<div
class="hidden md:flex items-center self-center text-xs text-gray-400 dark:text-gray-600 {className}"
>
<span>{keys.map(formatKey).join(isMac ? '' : '+')}</span>
</div>
{/if}

View file

@ -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 @@
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-new-chat-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
class="group grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
href="/"
draggable="false"
on:click={newChatHandler}
@ -797,16 +798,18 @@
<PencilSquare className=" size-4.5" strokeWidth="2" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class="flex flex-1 self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('New Chat')}</div>
</div>
<HotkeyHint name="newChat" />
</a>
</div>
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<button
id="sidebar-search-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
class="group grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
@ -817,9 +820,10 @@
<Search strokeWidth="2" className="size-4.5" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class="flex flex-1 self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
</div>
<HotkeyHint name="search" />
</button>
</div>

View file

@ -1446,6 +1446,7 @@
"Show image preview": "",
"Show Model": "",
"Show shortcuts": "",
"Show Sidebar Hotkey Hints": "",
"Show your support!": "",
"Showcased creativity": "",
"Sign in": "",

146
src/lib/shortcuts.ts Normal file
View file

@ -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.'
}
};

View file

@ -187,6 +187,7 @@ type Settings = {
highContrastMode?: boolean;
title?: TitleSettings;
showChatTitleInTab?: boolean;
showSidebarHotkeyHints?: boolean;
splitLargeDeltas?: boolean;
chatDirection?: 'LTR' | 'RTL' | 'auto';
ctrlEnterToSend?: boolean;

View file

@ -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;
}
</style>
</style>