diff --git a/packages/app/src/components/chat/ChatInput.vue b/packages/app/src/components/chat/ChatInput.vue
index ff84cf9c..ee6db914 100644
--- a/packages/app/src/components/chat/ChatInput.vue
+++ b/packages/app/src/components/chat/ChatInput.vue
@@ -59,6 +59,15 @@
+
+
+
(null)
const fileInputRef = ref
(null)
const images = ref([])
+const paletteRef = ref | 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() {
diff --git a/packages/app/src/components/chat/PromptPalette.vue b/packages/app/src/components/chat/PromptPalette.vue
new file mode 100644
index 00000000..d1999568
--- /dev/null
+++ b/packages/app/src/components/chat/PromptPalette.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
{{ selectedTemplate.title }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/stores/promptTemplates.ts b/packages/app/src/stores/promptTemplates.ts
new file mode 100644
index 00000000..3f7ba43b
--- /dev/null
+++ b/packages/app/src/stores/promptTemplates.ts
@@ -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(loadFromStorage())
+
+ const sortedTemplates = computed(() =>
+ [...templates.value].sort((a, b) => a.title.localeCompare(b.title))
+ )
+
+ function persist() {
+ saveToStorage(templates.value)
+ }
+
+ function addTemplate(data: Omit): PromptTemplate {
+ const template: PromptTemplate = { ...data, id: generateId() }
+ templates.value.push(template)
+ persist()
+ return template
+ }
+
+ function updateTemplate(id: string, data: Partial>) {
+ 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,
+ }
+})