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() {
@@ -0,0 +1,152 @@
<template>
<div v-if="isOpen" class="absolute bottom-full left-0 right-0 mb-1 z-20">
<!-- Variable fill form -->
<div
v-if="selectedTemplate && variables.length > 0"
class="glass-card p-4 space-y-3 animate-scale-in"
>
<div class="flex items-center justify-between">
<h4 class="text-xs font-semibold text-white/80">{{ selectedTemplate.title }}</h4>
<button
class="text-[10px] text-white/40 hover:text-white/60 transition-colors"
@click="cancelTemplate"
>
Cancel
</button>
</div>
<div v-for="v in variables" :key="v" class="space-y-1">
<label class="text-[11px] text-white/40">{{ v }}</label>
<input
v-model="variableValues[v]"
type="text"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white/90 focus:outline-none focus:border-accent/50"
:placeholder="v"
@keydown.enter="applyTemplate"
/>
</div>
<button
class="w-full py-1.5 rounded-lg text-xs bg-accent/20 text-accent hover:bg-accent/30 transition-all"
@click="applyTemplate"
>
Insert
</button>
</div>
<!-- Template list -->
<div
v-else
class="glass-card max-h-[240px] overflow-y-auto scrollbar-hide animate-scale-in"
>
<div class="p-2 border-b border-white/5">
<p class="text-[10px] text-white/30 px-2">Templates</p>
</div>
<div
v-for="(t, i) in filteredTemplates"
:key="t.id"
class="px-3 py-2 cursor-pointer transition-colors"
:class="i === highlightIndex ? 'bg-white/10' : 'hover:bg-white/5'"
@click="selectTemplate(t)"
@mouseenter="highlightIndex = i"
>
<p class="text-sm text-white/80">{{ t.title }}</p>
<p v-if="t.preview" class="text-[11px] text-white/40 mt-0.5 truncate">{{ t.preview }}</p>
</div>
<div v-if="filteredTemplates.length === 0" class="px-3 py-4 text-center">
<p class="text-xs text-white/30">No matching templates</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { usePromptTemplateStore, extractVariables, type PromptTemplate } from '@/stores/promptTemplates'
const props = defineProps<{
query: string
isOpen: boolean
}>()
const emit = defineEmits<{
select: [text: string]
close: []
}>()
const templateStore = usePromptTemplateStore()
const highlightIndex = ref(0)
const selectedTemplate = ref<PromptTemplate | null>(null)
const variableValues = ref<Record<string, string>>({})
const filteredTemplates = computed(() => {
const q = props.query.toLowerCase()
if (!q) return templateStore.sortedTemplates
return templateStore.sortedTemplates.filter(t =>
t.title.toLowerCase().includes(q) || (t.preview ?? '').toLowerCase().includes(q)
)
})
const variables = computed(() => {
if (!selectedTemplate.value) return []
return extractVariables(selectedTemplate.value.content)
})
watch(() => props.query, () => {
highlightIndex.value = 0
selectedTemplate.value = null
})
watch(() => props.isOpen, (open) => {
if (!open) {
selectedTemplate.value = null
variableValues.value = {}
highlightIndex.value = 0
}
})
function selectTemplate(t: PromptTemplate) {
const vars = extractVariables(t.content)
if (vars.length === 0) {
// No variables — insert directly
emit('select', t.content)
return
}
selectedTemplate.value = t
variableValues.value = Object.fromEntries(vars.map(v => [v, '']))
}
function applyTemplate() {
if (!selectedTemplate.value) return
let result = selectedTemplate.value.content
for (const [key, val] of Object.entries(variableValues.value)) {
result = result.replaceAll(`{{${key}}}`, val || key)
}
emit('select', result)
selectedTemplate.value = null
variableValues.value = {}
}
function cancelTemplate() {
selectedTemplate.value = null
variableValues.value = {}
}
function navigateUp() {
if (highlightIndex.value > 0) highlightIndex.value--
}
function navigateDown() {
if (highlightIndex.value < filteredTemplates.value.length - 1) highlightIndex.value++
}
function selectHighlighted() {
const t = filteredTemplates.value[highlightIndex.value]
if (t) selectTemplate(t)
}
defineExpose({
navigateUp,
navigateDown,
selectHighlighted,
hasSelectedTemplate: computed(() => !!selectedTemplate.value),
})
</script>
+136
View File
@@ -0,0 +1,136 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface PromptTemplate {
id: string
title: string
content: string
/** Preview text shown in palette */
preview?: string
}
const STORAGE_KEY = 'aiui-prompt-templates'
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
const DEFAULT_TEMPLATES: PromptTemplate[] = [
{
id: 'builtin-explain',
title: 'Explain like I\'m 5',
content: 'Explain {{topic}} in simple terms that a 5-year-old would understand.',
preview: 'Simplify complex topics',
},
{
id: 'builtin-compare',
title: 'Compare & Contrast',
content: 'Compare and contrast {{option A}} and {{option B}}. List pros, cons, and a recommendation.',
preview: 'Side-by-side analysis',
},
{
id: 'builtin-summarise',
title: 'Summarise',
content: 'Summarise the following in {{length}} bullet points:\n\n{{text}}',
preview: 'Condense text to key points',
},
{
id: 'builtin-translate',
title: 'Translate',
content: 'Translate the following to {{language}}:\n\n{{text}}',
preview: 'Translate text to another language',
},
]
function loadFromStorage(): PromptTemplate[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return [...DEFAULT_TEMPLATES]
return JSON.parse(raw)
} catch {
return [...DEFAULT_TEMPLATES]
}
}
function saveToStorage(templates: PromptTemplate[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates))
}
/** Extract variable names from template content */
export function extractVariables(content: string): string[] {
const matches = content.match(/\{\{([^}]+)\}\}/g)
if (!matches) return []
return [...new Set(matches.map(m => m.slice(2, -2).trim()))]
}
export const usePromptTemplateStore = defineStore('promptTemplates', () => {
const templates = ref<PromptTemplate[]>(loadFromStorage())
const sortedTemplates = computed(() =>
[...templates.value].sort((a, b) => a.title.localeCompare(b.title))
)
function persist() {
saveToStorage(templates.value)
}
function addTemplate(data: Omit<PromptTemplate, 'id'>): PromptTemplate {
const template: PromptTemplate = { ...data, id: generateId() }
templates.value.push(template)
persist()
return template
}
function updateTemplate(id: string, data: Partial<Omit<PromptTemplate, 'id'>>) {
const t = templates.value.find(x => x.id === id)
if (!t) return
Object.assign(t, data)
persist()
}
function deleteTemplate(id: string) {
const idx = templates.value.findIndex(x => x.id === id)
if (idx !== -1) {
templates.value.splice(idx, 1)
persist()
}
}
function exportTemplates(): string {
return JSON.stringify(templates.value, null, 2)
}
function importTemplates(json: string): number {
try {
const parsed = JSON.parse(json) as PromptTemplate[]
if (!Array.isArray(parsed)) return 0
let count = 0
for (const t of parsed) {
if (t.title && t.content && !templates.value.find(x => x.id === t.id)) {
templates.value.push({ id: t.id || generateId(), title: t.title, content: t.content, preview: t.preview })
count++
}
}
persist()
return count
} catch {
return 0
}
}
return {
templates,
sortedTemplates,
addTemplate,
updateTemplate,
deleteTemplate,
exportTemplates,
importTemplates,
}
})