Merge pull request #18473 from silentoplayz/hotkey-hints-sidebar

feat: add toggleable hotkey hints to sidebar buttons and refac ShortcutsModal
This commit is contained in:
Tim Baek 2025-10-22 16:50:24 -04:00 committed by GitHub
commit 710f6eec12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 492 additions and 471 deletions

View file

@ -1050,6 +1050,12 @@
dispatch('submit', prompt);
}}
>
<button
id="generate-message-pair-button"
class="hidden"
on:click={() => createMessagePair(prompt)}
/>
<div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
@ -1254,24 +1260,6 @@
stopResponse();
}
// Command/Ctrl + Shift + Enter to submit a message pair
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
e.preventDefault();
createMessagePair(prompt);
}
// Check if Ctrl + R is pressed
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
e.preventDefault();
console.log('regenerate');
const regenerateButton = [
...document.getElementsByClassName('regenerate-response-button')
]?.at(-1);
regenerateButton?.click();
}
if (prompt === '' && e.key == 'ArrowUp') {
e.preventDefault();

View file

@ -0,0 +1,71 @@
<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();
switch (lowerKey) {
case 'mod':
return isMac ? '⌘' : 'Ctrl';
case 'shift':
return isMac ? '⇧' : 'Shift';
case 'alt':
return isMac ? '⌥' : 'Alt';
case 'backspace':
case 'delete':
return isMac ? '⌫' : 'Delete';
case 'escape':
return 'Esc';
case 'enter':
return isMac ? '↩' : 'Enter';
case 'tab':
return isMac ? '⇥' : 'Tab';
case 'arrowup':
return '↑';
case 'arrowdown':
return '↓';
case 'quote':
return "'";
case 'period':
return '.';
case 'slash':
return '/';
case 'semicolon':
return ';';
default:
if (lowerKey.startsWith('key')) return key.slice(-1);
if (lowerKey.startsWith('digit')) return key.slice(-1);
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 flex-wrap justify-end items-center self-center space-x-1 text-xs">
{#each shortcut.keys.filter(key => !(key.toLowerCase() === 'delete' && shortcut.keys.includes('Backspace'))) 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,118 @@
<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 { settings } from '$lib/stores';
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).filter((shortcut) => {
if (!shortcut.setting) {
return true;
}
return $settings[shortcut.setting.id] === shortcut.setting.value;
});
const result = allShortcuts.reduce((acc, shortcut) => {
const category = shortcut.category;
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(shortcut);
return acc;
}, {});
const newCategorizedShortcuts = {};
for (const category in result) {
const half = Math.ceil(result[category].length / 2);
newCategorizedShortcuts[category] = {
left: result[category].slice(0, half),
right: result[category].slice(half)
};
}
categorizedShortcuts = newCategorizedShortcuts;
}
</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('Keyboard 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,37 @@
<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;
});
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": "",

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

@ -0,0 +1,178 @@
type ShortcutRegistry = {
[key in Shortcut]?: {
name: string;
keys: string[];
category: string;
tooltip?: string;
setting?: {
id: string;
value: any;
};
};
};
export enum Shortcut {
//Chat
NEW_CHAT = 'newChat',
NEW_TEMPORARY_CHAT = 'newTemporaryChat',
DELETE_CHAT = 'deleteChat',
//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',
SEND_MESSAGE_NORMAL = 'sendMessageNormal',
SEND_MESSAGE_MOD = 'sendMessageMod',
ATTACH_FILE = 'attachFile',
ADD_PROMPT = 'addPrompt',
TALK_TO_MODEL = 'talkToModel',
//Message
GENERATE_MESSAGE_PAIR = 'generateMessagePair',
REGENERATE_RESPONSE = 'regenerateResponse',
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', 'Delete'],
category: 'Chat'
},
//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 Chat Input',
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.SEND_MESSAGE_NORMAL]: {
name: 'Send Message',
keys: ['Enter'],
category: 'Input',
tooltip: 'The behavior of this shortcut is determined by the "Enter Key Behavior" setting.',
setting: {
id: 'ctrlEnterToSend',
value: false
}
},
[Shortcut.SEND_MESSAGE_MOD]: {
name: 'Send Message',
keys: ['mod', 'Enter'],
category: 'Input',
tooltip: 'The behavior of this shortcut is determined by the "Enter Key Behavior" setting.',
setting: {
id: 'ctrlEnterToSend',
value: true
}
},
[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.GENERATE_MESSAGE_PAIR]: {
name: 'Generate Message Pair',
keys: ['mod', 'shift', 'Enter'],
category: 'Message',
tooltip: 'Only active when the chat input is in focus.'
},
[Shortcut.REGENERATE_RESPONSE]: {
name: 'Regenerate Response',
keys: ['mod', 'KeyR'],
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.'
},
[Shortcut.NAVIGATE_PROMPT_HISTORY_UP]: {
name: 'Edit Last Message',
keys: ['ArrowUp'],
category: 'Message',
tooltip: 'Only can be triggered when the chat input is in focus.'
},
[Shortcut.COPY_LAST_RESPONSE]: {
name: 'Copy Last Response',
keys: ['mod', 'shift', 'KeyC'],
category: 'Message'
},
[Shortcut.COPY_LAST_CODE_BLOCK]: {
name: 'Copy Last Code Block',
keys: ['mod', 'shift', 'Semicolon'],
category: 'Message'
}
};

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,120 @@
})
]);
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_MESSAGE_PAIR:
event.preventDefault();
document.getElementById('generate-message-pair-button')?.click();
break;
case Shortcut.REGENERATE_RESPONSE:
event.preventDefault();
[...document.getElementsByClassName('regenerate-response-button')]?.at(-1)?.click();
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 +461,4 @@
cursor: pointer;
background-color: #bcbabb;
}
</style>
</style>