feat(chat): add prompt template library with / command palette (M9.3)

Type / in chat input to open glass command palette with prompt templates.
Templates support {{variable}} substitution with a mini form. Built-in
templates for Explain, Compare, Summarise, Translate. Templates stored
in localStorage, importable/exportable as JSON. Arrow key navigation
and Enter to select.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:30:55 +00:00
co-authored by Claude Opus 4.6
parent 77b985db9c
commit 751edd2790
3 changed files with 356 additions and 1 deletions
+68 -1
View File
@@ -59,6 +59,15 @@
</span>
</div>
<!-- Prompt palette -->
<PromptPalette
ref="paletteRef"
:query="paletteQuery"
:is-open="isPaletteMode"
@select="handlePaletteSelect"
@close="closePalette"
/>
<!-- Drag overlay -->
<div
v-if="isDragging"
@@ -89,7 +98,7 @@
rows="1"
:placeholder="placeholder"
class="flex-1 resize-none bg-transparent text-sm outline-none min-h-[24px] max-h-[120px] text-white/90 placeholder:text-white/25"
@keydown.enter.exact.prevent="send"
@keydown="handleKeydown"
@input="autoResize"
@paste="onPaste"
@focus="focused = true"
@@ -152,6 +161,7 @@
import { ref, computed, nextTick, watch } from 'vue'
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
import SearchResults from '@/components/ui/SearchResults.vue'
import PromptPalette from './PromptPalette.vue'
import type { ImageAttachment } from '@aiui/core/types/message'
const MAX_IMAGES = 4
@@ -187,6 +197,63 @@ const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const images = ref<ImageAttachment[]>([])
const paletteRef = ref<InstanceType<typeof PromptPalette> | null>(null)
// Prompt palette: opens when text starts with "/" (but not "/search " or "/code" etc.)
const isPaletteMode = computed(() => {
const t = text.value.trimStart()
return t === '/' || (t.startsWith('/') && !t.includes(' ') && !t.startsWith('/search') && !t.startsWith('/code') && !t.startsWith('/nostr') && !t.startsWith('/exit'))
})
const paletteQuery = computed(() => {
if (!isPaletteMode.value) return ''
return text.value.trimStart().slice(1) // strip leading /
})
function handlePaletteSelect(templateText: string) {
text.value = templateText
nextTick(() => {
autoResize()
textareaRef.value?.focus()
})
}
function closePalette() {
// Add a space to exit palette mode
if (text.value.trimStart() === '/') {
text.value = ''
}
}
function handleKeydown(e: KeyboardEvent) {
if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) {
if (e.key === 'ArrowUp') {
e.preventDefault()
paletteRef.value?.navigateUp()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
paletteRef.value?.navigateDown()
return
}
if (e.key === 'Enter') {
e.preventDefault()
paletteRef.value?.selectHighlighted()
return
}
if (e.key === 'Escape') {
e.preventDefault()
closePalette()
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
const canSend = computed(() => (text.value.trim().length > 0 || images.value.length > 0) && !props.disabled)
function send() {