feat: add keyboard shortcut tooltips

This commit is contained in:
Zoe
2026-03-02 13:41:57 -06:00
parent 579f4730c8
commit e2a4643419
16 changed files with 217 additions and 53 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
- [ ] Fix custom longcat tool call
- [ ] add the ability to make a custom provider
- [ ] add browser testing (maybe https://vitest.dev/guide/browser/)
- [ ] Add a dropdown to the output token count on agent messages that shows you all the token info
- [x] Add a dropdown to the output token count on agent messages that shows you all the token info
- [ ] Add the ability to configure UI scale and font size
- [ ] Write good docs
- [ ] Custom theme colors
-4
View File
@@ -207,10 +207,6 @@ button.accent:hover {
background-color: var(--color-accent-hover);
}
.capitalize {
text-transform: capitalize;
}
.has-obtrusive-scrollbars .chat-scroll-container {
scrollbar-gutter: stable;
}
+2 -1
View File
@@ -207,7 +207,8 @@ onUnmounted(() => {
<div class="flex items-center justify-between gap-2">
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
<div class="flex flex-1 gap-1">
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers" />
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
:providers="providers" />
<FileSelector :selected-model="selectedModel" v-model="files" />
</div>
<button aria-label="Send message" @click="handleSubmit"
+1 -1
View File
@@ -122,7 +122,7 @@ onMounted(() => { inputRef.value?.focus(); });
<input ref="inputRef" v-model="query" type="text" role="combobox" aria-autocomplete="list"
aria-haspopup="listbox" :aria-expanded="flattenedResults.length > 0" :aria-controls="listboxId"
:aria-activedescendant="flattenedResults[selectedIndex]?.uiId" placeholder="Search agents and topics..."
class="w-full bg-transparent border-none outline-none text-lg text-[var(--text-primary)]"
class="placeholder:text-[var(--text-tertiary)] w-full bg-transparent border-none outline-none text-lg text-[var(--text-primary)]"
@keydown="handleKeyDown" />
</div>
+1 -1
View File
@@ -89,7 +89,7 @@ const runtimePage = computed(() => {
<main
class="flex-1 flex flex-col overflow-y-hidden max-h-full h-full px-3 bg-[var(--bg-surface)] border rounded-lg border-[var(--color-border)]">
<header class="flex items-center justify-between pl-2 pb-2 pt-2 ">
<h2 class="text-lg font-semibold m-0 capitalize">{{ runtimePage.label }}</h2>
<h2 class="text-lg font-semibold m-0 case-capital">{{ runtimePage.label }}</h2>
<button
class="hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
@click="close">
+1 -1
View File
@@ -68,7 +68,7 @@ console.log("shiki codeblock rendered in", Date.now() - start);
<template>
<div class="flex flex-col my-2 rounded-xl overflow-hidden">
<div class="flex items-center pl-3 pr-1.5 py-1.5 text-sm font-sans bg-[var(--color-hover)] justify-between">
<div class="capitalize">
<div class="case-capital">
{{ parsed?.displayLang }}
</div>
<div class="flex gap-2">
+12 -2
View File
@@ -138,26 +138,34 @@ const messageCount = computed(() => {
<div class="flex justify-between items-center">
<div>
<div v-if="messageCount > 1" class="flex gap-1">
<button :inert="focusedIndex === 0" @click="focusedIndex = focusedIndex! - 1">
<Tooltip :inert="focusedIndex === 0" :hotkey="['alt', '[']">
<button @click="focusedIndex = focusedIndex! - 1"
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-left w-4 h-4 text-[var(--text-secondary)]"
:class="focusedIndex === 0 ? 'opacity-0' : ''"></span>
</button>
</Tooltip>
<span class="text-xs text-[var(--text-secondary)]">
{{ focusedIndex + 1 }} / {{ messageCount }}
</span>
<button :inert="focusedIndex + 1 === messageCount" @click="focusedIndex = focusedIndex + 1">
<Tooltip :inert="focusedIndex + 1 === messageCount" :hotkey="['alt', ']']">
<button @click="focusedIndex = focusedIndex + 1"
class="hover:bg-[var(--color-hover)] rounded transition duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-chevron-right w-4 h-4 text-[var(--text-secondary)]"
:class="focusedIndex + 1 === messageCount ? 'opacity-0' : ''"></span>
</button>
</Tooltip>
</div>
</div>
<div v-show="activeMessage.generation?.status !== 'pending'"
class="self-end mt-1 w-fit bg-[var(--bg-container)] text-[var(--text-secondary)] gap-px flex items-center rounded-md overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<Tooltip :hotkey="['ctrl', 'shift', 'enter']">
<button @click="regenerateMessage"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
<span class="i-mynaui-refresh text-4.5"></span>
</button>
</Tooltip>
<button v-if="message.role === 'user'" @click="handleEdit"
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
<span class="i-mynaui-pencil text-4.5"></span>
@@ -166,10 +174,12 @@ const messageCount = computed(() => {
class="flex justify-center items-center w-7 h-6 hover:bg-[var(--color-hover)]">
<span :class="copied ? 'i-mynaui-check text-emerald-500' : 'i-mynaui-copy text-4.5'"></span>
</button>
<Tooltip :hotkey="['ctrl', 'shift', 'backspace']">
<button @click="deleteMessage"
class="flex justify-center items-center w-7 h-6 text-red-500 hover:bg-[var(--color-hover)]">
<span class="i-mynaui-trash text-5"></span>
</button>
</Tooltip>
</div>
</div>
</div>
+8 -3
View File
@@ -12,6 +12,7 @@ const { addShortcut } = useKeyboardShortcuts();
const props = defineProps<{
providers: ProviderWithModels[];
addHotkey?: boolean;
}>();
const selectedModel = defineModel<ModelWithProvider | null>();
@@ -182,11 +183,15 @@ const handleSearchKeyDown = (event: KeyboardEvent) => {
}
};
const unbindShortcut = addShortcut(['ctrl', 'shift', 'm'], (event) => {
let unbindShortcut: () => void = () => { };
if (props.addHotkey) {
unbindShortcut = addShortcut(['ctrl', 'shift', 'm'], (event) => {
event.preventDefault();
event.stopPropagation();
toggleDropdown();
});
});
}
onUnmounted(() => {
unbindShortcut();
@@ -240,7 +245,7 @@ onUnmounted(() => {
<div v-for="provider in filteredProviders" :key="provider.id" class="mb-2">
<div v-if="provider.models.filter(m => m.enabled).length > 0"
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] capitalize tracking-wider flex justify-between">
class="px-4 py-1.5 text-[13px] font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
{{ provider.name }}
<button
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: provider.id })"
+2 -2
View File
@@ -61,7 +61,7 @@ defineEmits(['navigate']);
<div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-8 h-8 text-[var(--text-primary)]" />
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
<h3 class="text-md font-semibold text-start case-capital">{{ p.name }}</h3>
</div>
<hr class="border-t border-[var(--color-border)]" />
</div>
@@ -87,7 +87,7 @@ defineEmits(['navigate']);
<div class="flex items-center gap-2 mb-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-8 h-8 text-[var(--text-primary)]" />
<h3 class="text-md font-semibold text-start capitalize">{{ p.name }}</h3>
<h3 class="text-md font-semibold text-start case-capital">{{ p.name }}</h3>
</div>
<hr class="border-t border-[var(--color-border)]" />
</div>
+2 -2
View File
@@ -24,7 +24,7 @@ defineEmits(['navigate']);
<div class="px-2 py-4 font-bold text-xs uppercase opacity-50">Enabled Providers</div>
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
:class="['capitalize flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
:class="['case-capital flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-4 h-4 text-[var(--text-primary)]" />
@@ -36,7 +36,7 @@ defineEmits(['navigate']);
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
@click="$emit('navigate', 'providers', p.id)"
:class="['capitalize flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
:class="['case-capital flex items-center justify-between p-2 hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
<div class="flex items-center gap-2">
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
class="w-4 h-4 text-[var(--text-primary)]" />
+1 -1
View File
@@ -47,7 +47,7 @@ defineEmits(['navigate']);
class="flex flex-col gap-4 py-4 flex-grow overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key">
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
<h4 class="capitalize">{{ key }}</h4>
<h4 class="case-capital">{{ key }}</h4>
<Slider :id="`slider-${key}`" :checked="systemAssistant.enabled" @click="toggle(String(key))" />
</label>
<div class="flex-1 justify-between gap-4 items-center">
+6
View File
@@ -120,6 +120,7 @@ const navKind = computed(() => {
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
<div :style="isHovering ? 'width: 32px;' : 'width: 0px;'"
:class="['flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right']">
<Tooltip :hotkey="['ctrl', '[']">
<button aria-label="close sidebar" @click="closeSidebar" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit',
]">
@@ -127,13 +128,16 @@ const navKind = computed(() => {
class="i-mynaui-panel-left-close text-5 transition-inherit transform-origin-right-center"
:class="isHovering ? 'opacity-100 scale-100' : 'opacity-0 scale-95'"></span>
</button>
</Tooltip>
</div>
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
<Tooltip :hotkey="['ctrl', 'alt', 'n']">
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
'flex text-5 h-8 w-8 items-center justify-center hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit',
]">
<span class="i-mynaui-book-plus text-5"></span>
</NuxtLink>
</Tooltip>
</div>
</div>
</div>
@@ -145,10 +149,12 @@ const navKind = computed(() => {
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
<div class="flex">
<Tooltip :hotkey="['ctrl', ',']">
<button @click="openDialog(DialogType.Settings)"
class="flex items-center justify-center h-7 w-7 cursor-pointer hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]">
<span class="i-mynaui-cog-four text-5"></span>
</button>
</Tooltip>
</div>
<div class="flex justify-end gap-1">
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
const props = defineProps<{
hotkey?: string[];
}>();
const { show, hide } = useTooltip();
const triggerRef = ref<HTMLElement | null>(null);
const onMouseEnter = () => {
if (triggerRef.value) {
show(triggerRef.value, props.hotkey);
}
};
</script>
<template>
<div ref="triggerRef" @mouseenter="onMouseEnter" @mouseleave="hide" :class="$attrs.class" v-bind="$attrs">
<slot />
</div>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue';
const { activeElement, activeHotkey, isVisible } = useTooltip();
watch(activeElement, () => {
console.log('activeElement', activeElement.value);
});
const tooltipRef = ref<HTMLElement | null>(null);
const { floatingStyles, placement } = useFloating(activeElement, tooltipRef, {
placement: 'top',
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 8 })],
transform: false,
});
const isMoving = ref(false);
watch(activeElement, (newEl, oldEl) => {
// If we have both a new and old element, and we're currently visible,
// then we are "moving" and should animate the transform.
if (newEl && oldEl && isVisible.value) {
isMoving.value = true;
} else {
isMoving.value = false;
}
});
// Also reset isMoving when the tooltip fully closes
watch(isVisible, (visible) => {
if (!visible) isMoving.value = false;
});
const isMac = import.meta.client ? navigator.userAgent.toUpperCase().indexOf('MAC') >= 0 : false;
const displayHotkey = computed(() =>
activeHotkey.value?.map((key: string) =>
isMac ? key.toLowerCase().replace('ctrl', '⌘') : key.toLowerCase())
);
const transformOrigin = computed(() => placement.value.includes('top') ? 'transform-origin-bottom-center' : 'transform-origin-top-center');
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100"
leave-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
<div v-if="isVisible && activeElement" ref="tooltipRef" :style="[
floatingStyles,
{
// Only apply the transform transition when moving between elements
transitionProperty: isMoving
? 'top, left, right, bottom, opacity, transform'
: 'opacity, transform',
transitionDuration: '150ms',
transitionTimingFunction: 'cubic-bezier(0.5, 1, 0.89, 1)'
}
]" :class="transformOrigin"
class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs">
<div class="flex gap-1 items-center">
<span v-for="key in displayHotkey" :key="key"
class="bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
<kbd class="font-mono text-[10px] case-capital">{{ key }}</kbd>
</span>
</div>
</div>
</Transition>
</Teleport>
</template>
+51
View File
@@ -0,0 +1,51 @@
const dwellTimeout = 400;
const persistTimeout = 150;
export const useTooltip = () => {
const activeElement = useState<HTMLElement | null>('tooltip:activeElement', () => null);
const activeHotkey = useState<string[] | null>('tooltip:activeHotkey', () => null);
const isVisible = useState<boolean>('tooltip:isVisible', () => false);
const dwellTimeoutId = useState<NodeJS.Timeout | null>('tooltip:dwellTimeoutId', () => null);
const closeTimeoutId = useState<NodeJS.Timeout | null>('tooltip:closeTimeout', () => null);
const show = (el: HTMLElement, hotkey?: string[]) => {
if (closeTimeoutId.value) {
clearTimeout(closeTimeoutId.value);
}
if (isVisible.value) {
activeElement.value = el;
activeHotkey.value = hotkey || null;
} else {
dwellTimeoutId.value = setTimeout(() => {
activeElement.value = el;
activeHotkey.value = hotkey || null;
isVisible.value = true;
}, dwellTimeout);
}
};
const hide = () => {
if (dwellTimeoutId.value) {
clearTimeout(dwellTimeoutId.value);
}
if (closeTimeoutId.value) {
clearTimeout(closeTimeoutId.value);
}
closeTimeoutId.value = setTimeout(() => {
isVisible.value = false;
activeElement.value = null;
closeTimeoutId.value = null;
}, persistTimeout);
};
return {
activeElement: readonly(activeElement),
activeHotkey: readonly(activeHotkey),
isVisible: readonly(isVisible),
show,
hide,
};
};
+1
View File
@@ -30,5 +30,6 @@ useHead({
</div>
<ClientOnly>
<Dialog />
<TooltipProvider />
</ClientOnly>
</template>