/* commands.ts — Slash commands (/command) for the WebUI */ /// interface CmdDef { name: string; desc: string; arg: string; aliases: string[]; fn: (args: string) => void; } const PASSTHROUGH = ['retry','undo','title','branch','stop','background','btw', 'queue','status','profile','resume','snapshot','rollback','provider', 'yolo','reasoning','fast','voice','reload','reload-mcp','cron','browser', 'plugins','insights','platforms','debug','update','image','inbox']; let COMMANDS: CmdDef[] = []; const AGENT_INFO: Record = { 'sunflower': { emoji: '\uD83C\uDF3B', name: 'Sunflower', file: 'sunflower/soul.md', domain: 'Finance, Wealth & Subscriptions' }, 'lotus': { emoji: '\uD83E\uDDD7', name: 'Lotus', file: 'lotus/soul.md', domain: 'Health, Fitness & Recovery' }, 'forget-me-not': { emoji: '\uD83C\uDF3C', name: 'Forget-me-not', file: 'forget-me-not/soul.md', domain: 'Calendar, Time & Social' }, 'iris': { emoji: '\u2695\uFE0F', name: 'Iris', file: 'iris/soul.md', domain: 'Career, Learning & Focus' }, 'ivy': { emoji: '\uD83C\uDF3F', name: 'Ivy', file: 'ivy/soul.md', domain: 'Smart Home & Environment' }, 'dandelion': { emoji: '\uD83D\uDEE1', name: 'Dandelion', file: 'dandelion/soul.md', domain: 'Communication Triage & Gatekeeping' }, 'root': { emoji: '\uD83C\uDF33', name: 'Root', file: 'root/soul.md', domain: 'DevOps, Logs & System Health' }, 'back': { emoji: '\uD83C\uDF39', name: 'Rose', file: 'rose/soul.md', domain: 'Orchestrator (return from agent)' }, }; function _fnFor(name: string): (args: string) => void { if (name === 'help' || name === 'commands') return cmdHelp; if (name === 'clear') return cmdClear; if (name === 'compact' || name === 'compress') return cmdCompact; if (name === 'model') return cmdModel; if (name === 'workspace') return cmdWorkspace; if (name === 'new') return cmdNew; if (name === 'usage') return cmdUsage; if (name === 'theme') return cmdTheme; if (name === 'skills') return cmdSkills; if (name === 'personality') return cmdPersonality; if (PASSTHROUGH.includes(name)) return cmdPassthrough; return cmdPassthrough; } async function loadCommands(): Promise { try { const data = await api('/api/commands') as { error?: string; categories?: Record> }; if (data.error) throw new Error(data.error); const cats = data.categories || {}; const merged: CmdDef[] = []; for (const [, cmds] of Object.entries(cats)) { for (const c of cmds) { merged.push({ name: c.name, desc: c.desc, arg: c.arg || '(none)', aliases: c.aliases || [], fn: _fnFor(c.name) }); } } const agentNames = ['sunflower','lotus','forget-me-not','iris','ivy','dandelion','root','back','inbox']; const filtered = merged.filter(c => !agentNames.includes(c.name)); filtered.push( { name: 'sunflower', desc: '\uD83C\uDF3B Finance, Wealth & Subscriptions', fn: cmdAgent, arg: 'message', aliases: [] }, { name: 'lotus', desc: '\uD83E\uDDD7 Health, Fitness & Recovery', fn: cmdAgent, arg: 'message', aliases: [] }, { name: 'forget-me-not', desc: '\uD83C\uDF3C Calendar, Time & Social', fn: cmdAgent, arg: 'message', aliases: [] }, { name: 'iris', desc: '\u2695\uFE0F Career, Learning & Focus', fn: cmdAgent, arg: 'message', aliases: [] }, { name: 'ivy', desc: '\uD83C\uDF3F Smart Home & Environment', fn: cmdAgent, arg: 'message', aliases: [] }, { name: 'dandelion', desc: '\uD83D\uDEE1 Communication Triage & Gatekeeping', fn: cmdAgent, arg: 'message', aliases: [] }, { name: 'root', desc: '\uD83C\uDF33 DevOps, Logs & System Health', fn: cmdAgent, arg: 'message', aliases: [] }, { name: 'back', desc: '\uD83C\uDF39 Return to Rose (orchestrator)', fn: cmdAgent, arg: 'message', aliases: [] }, ); COMMANDS = filtered; } catch (e: unknown) { console.warn('[commands] Failed to load from API, using fallback:', (e instanceof Error) ? e.message : String(e)); COMMANDS = []; } } interface ParsedCommand { name: string; args: string; } function parseCommand(text: string): ParsedCommand | null { if (!text.startsWith('/')) return null; const parts = text.slice(1).split(/\s+/); const name = parts[0].toLowerCase(); const args = parts.slice(1).join(' ').trim(); return { name, args }; } function executeCommand(text: string): boolean { const parsed = parseCommand(text); if (!parsed) return false; const cmd = COMMANDS.find(c => c.name === parsed.name); if (!cmd) return false; cmd.fn(parsed.args); return true; } function getMatchingCommands(prefix: string): CmdDef[] { const q = prefix.toLowerCase(); return COMMANDS.filter(c => { if (c.name.startsWith(q)) return true; if (c.aliases && c.aliases.some(a => a.startsWith(q))) return true; return false; }); } function cmdPassthrough(_args: string): void { const msgEl = $('msg') as HTMLTextAreaElement | null; if (!msgEl) return; const parsed = parseCommand(msgEl.value); if (!parsed) return; send(); } function cmdHelp(): void { const categories: Record = { 'Session': [], 'Configuration': [], 'Tools & Skills': [], 'Info': [], 'Agents': [] }; COMMANDS.forEach(c => { let cat = 'Info'; if (['new','clear','compact','compress','retry','undo','title','branch','stop','background','btw','queue','status','profile','resume','snapshot','rollback'].includes(c.name)) cat = 'Session'; else if (['model','provider','personality','workspace','theme','yolo','reasoning','fast','voice','reload','reload-mcp'].includes(c.name)) cat = 'Configuration'; else if (['skills','cron','browser','plugins'].includes(c.name)) cat = 'Tools & Skills'; else if (['sunflower','lotus','forget-me-not','iris','ivy','dandelion','root','back','inbox'].includes(c.name)) cat = 'Agents'; if (!categories[cat]) categories[cat] = []; categories[cat].push(c); }); const lines: string[] = []; for (const [cat, cmds] of Object.entries(categories)) { if (!cmds.length) continue; lines.push(`\n**${cat}**`); cmds.forEach(c => { const usage = c.arg && c.arg !== '(none)' ? ` <${c.arg}>` : ''; lines.push(` /${c.name}${usage} \u2014 ${c.desc}`); }); } const msg = { role: 'assistant' as const, content: 'Available commands:\n' + lines.join('\n') }; S.messages.push(msg); renderMessages(); showToast(t('type_slash')); } function cmdClear(): void { if (!S.session) return; S.messages = []; S.toolCalls = []; clearLiveToolCards(); renderMessages(); const emptyState = $('emptyState'); if (emptyState) emptyState.style.display = ''; showToast(t('conversation_cleared')); } async function cmdModel(args: string): Promise { if (!args) { showToast('Usage: /model '); return; } const sel = $('modelSelect') as unknown as HTMLSelectElement | null; if (!sel) return; const q = args.toLowerCase(); let match: string | null = null; for (const opt of sel.options) { if (opt.value.toLowerCase().includes(q) || (opt.textContent || '').toLowerCase().includes(q)) { match = opt.value; break; } } if (!match) { showToast('No model matching "' + args + '"'); return; } sel.value = match; if (sel.onchange) await (sel as HTMLSelectElement).onchange!(null as unknown as Event); showToast(t('switched_to') + match); } async function cmdWorkspace(args: string): Promise { if (!args) { showToast('Usage: /workspace '); return; } try { const data = await api('/api/workspaces') as { workspaces?: Array<{ name?: string; path: string }> }; const q = args.toLowerCase(); const ws = (data.workspaces || []).find(w => (w.name || '').toLowerCase().includes(q) || w.path.toLowerCase().includes(q) ); if (!ws) { showToast('No workspace matching "' + args + '"'); return; } if (typeof switchToWorkspace === 'function') await switchToWorkspace(ws.path, ws.name || ws.path); else showToast(t('switched_workspace') + (ws.name || ws.path)); } catch (e: unknown) { showToast(t('workspace_switch_failed') + ((e instanceof Error) ? e.message : String(e))); } } async function cmdNew(): Promise { await newSession(); await renderSessionList(); const msgEl = $('msg') as HTMLTextAreaElement | null; if (msgEl) msgEl.focus(); showToast(t('new_session')); } function cmdCompact(): void { const msgEl = $('msg') as HTMLTextAreaElement | null; if (msgEl) msgEl.value = 'Please compress and summarize the conversation context to free up space.'; send(); showToast(t('compressing')); } async function cmdUsage(): Promise { const next = !window._showTokenUsage; window._showTokenUsage = next; try { await api('/api/settings', { method: 'POST', body: JSON.stringify({ show_token_usage: next }) }); } catch { /* noop */ } const cb = $('settingsShowTokenUsage') as HTMLInputElement | null; if (cb) cb.checked = next; renderMessages(); showToast(next ? t('token_usage_on') : t('token_usage_off')); } async function cmdTheme(args: string): Promise { const themes = ['system','dark','light','slate','solarized','monokai','nord','oled']; if (!args || !themes.includes(args.toLowerCase())) { showToast('Themes: ' + themes.join(' | ')); return; } const themeName = args.toLowerCase(); localStorage.setItem('hermes-theme', themeName); _applyTheme(themeName); try { await api('/api/settings', { method: 'POST', body: JSON.stringify({ theme: themeName }) }); } catch { /* noop */ } const sel = $('settingsTheme') as unknown as HTMLSelectElement | null; if (sel) sel.value = themeName; showToast(t('theme_set') + themeName); } async function cmdSkills(args: string): Promise { try { const data = await api('/api/skills') as { skills?: Array<{ name?: string; description?: string; category?: string }> }; let skills = data.skills || []; if (args) { const q = args.toLowerCase(); skills = skills.filter(s => (s.name || '').toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q) || (s.category || '').toLowerCase().includes(q) ); } if (!skills.length) { const msg = { role: 'assistant' as const, content: args ? `No skills matching "${args}".` : 'No skills found.' }; S.messages.push(msg); renderMessages(); return; } const byCategory: Record = {}; skills.forEach(s => { const cat = s.category || 'General'; if (!byCategory[cat]) byCategory[cat] = []; byCategory[cat].push(s); }); const lines: string[] = []; for (const [cat, items] of Object.entries(byCategory).sort()) { lines.push(`**${cat}**`); items.forEach(s => { const desc = s.description ? ` \u2014 ${s.description.slice(0, 80)}${s.description.length > 80 ? '...' : ''}` : ''; lines.push(` \`${s.name}\`${desc}`); }); lines.push(''); } const header = args ? `Skills matching "${args}" (${skills.length}):\n\n` : `Available skills (${skills.length}):\n\n`; S.messages.push({ role: 'assistant' as const, content: header + lines.join('\n') }); renderMessages(); } catch (e: unknown) { showToast('Failed to load skills: ' + (e instanceof Error ? e.message : String(e))); } } async function cmdPersonality(args: string): Promise { if (!S.session) { showToast(t('no_active_session')); return; } if (!args) { try { const data = await api('/api/personalities') as { personalities?: Array<{ name: string; description?: string }> }; if (!data.personalities || !data.personalities.length) { showToast(t('no_personalities')); return; } const list = data.personalities.map(p => ` **${p.name}**${p.description ? ' \u2014 ' + p.description : ''}`).join('\n'); S.messages.push({ role: 'assistant' as const, content: t('available_personalities') + '\n\n' + list + '\n\nSwitch with: /personality ' }); renderMessages(); } catch { showToast(t('personalities_load_failed')); } return; } const name = args.trim(); if (['none','default','clear'].includes(name.toLowerCase())) { try { await api('/api/personality/set', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, name: '' }) }); showToast(t('personality_cleared')); } catch (e: unknown) { showToast(t('failed_colon') + (e instanceof Error ? e.message : String(e))); } return; } try { await api('/api/personality/set', { method: 'POST', body: JSON.stringify({ session_id: S.session.session_id, name }) }); showToast(t('personality_set') + name); } catch (e: unknown) { showToast(t('failed_colon') + (e instanceof Error ? e.message : String(e))); } } function cmdAgent(_args: string): void { const msgEl = $('msg') as HTMLTextAreaElement | null; if (!msgEl) return; const parsed = parseCommand(msgEl.value); if (!parsed) return; const agentKey = parsed.name; const info = AGENT_INFO[agentKey]; if (!info) { showToast('Unknown agent: ' + agentKey); return; } const userMsg = _args || ''; const contextMsg = `[Agent Switch: ${info.emoji} ${info.name}]\nLoad ~/.hermes/agents/${info.file} and handle this request as ${info.name} (${info.domain}).${userMsg ? '\n\nUser message: ' + userMsg : ''}`; msgEl.value = contextMsg; send(); } let _cmdSelectedIdx = -1; function showCmdDropdown(matches: CmdDef[]): void { const dd = $('cmdDropdown'); if (!dd) return; dd.innerHTML = ''; _cmdSelectedIdx = -1; for (let i = 0; i < matches.length; i++) { const c = matches[i]; const el = document.createElement('div'); el.className = 'cmd-item'; (el as HTMLElement).dataset.idx = String(i); const usage = c.arg && c.arg !== '(none)' ? ` ${esc(c.arg)}` : ''; el.innerHTML = `
/${esc(c.name)}${usage}
${esc(c.desc)}
`; el.addEventListener('mousedown', (e) => { e.preventDefault(); const msgEl2 = $('msg') as HTMLTextAreaElement | null; if (msgEl2) { msgEl2.value = '/' + c.name + (c.arg && c.arg !== '(none)' ? ' ' : ''); msgEl2.focus(); } hideCmdDropdown(); }); dd.appendChild(el); } dd.classList.add('open'); } function hideCmdDropdown(): void { const dd = $('cmdDropdown'); if (dd) dd.classList.remove('open'); _cmdSelectedIdx = -1; } function navigateCmdDropdown(dir: number): void { const dd = $('cmdDropdown'); if (!dd) return; const items = dd.querySelectorAll('.cmd-item'); if (!items.length) return; items.forEach(el => el.classList.remove('selected')); _cmdSelectedIdx += dir; if (_cmdSelectedIdx < 0) _cmdSelectedIdx = items.length - 1; if (_cmdSelectedIdx >= items.length) _cmdSelectedIdx = 0; items[_cmdSelectedIdx].classList.add('selected'); } function selectCmdDropdownItem(): void { const dd = $('cmdDropdown'); if (!dd) return; const items = dd.querySelectorAll('.cmd-item'); if (_cmdSelectedIdx >= 0 && _cmdSelectedIdx < items.length) { const item = items[_cmdSelectedIdx] as unknown as HTMLElement; const ev = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); Object.defineProperty(ev, 'preventDefault', { value: () => {} }); item.dispatchEvent(ev); } else if (items.length === 1) { const ev = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); Object.defineProperty(ev, 'preventDefault', { value: () => {} }); (items[0] as unknown as HTMLElement).dispatchEvent(ev); } hideCmdDropdown(); } // Theme application stub (actual implementation is in ui.ts) function _applyTheme(themeName: string): void { document.documentElement.dataset.theme = themeName; } export { COMMANDS, PASSTHROUGH, AGENT_INFO, loadCommands, parseCommand, executeCommand, getMatchingCommands, showCmdDropdown, hideCmdDropdown, navigateCmdDropdown, selectCmdDropdownItem };