359 lines
16 KiB
TypeScript
359 lines
16 KiB
TypeScript
/* commands.ts — Slash commands (/command) for the WebUI */
|
|
/// <reference path="./global.d.ts" />
|
|
|
|
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<string, { emoji: string; name: string; file: string; domain: string }> = {
|
|
'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<void> {
|
|
try {
|
|
const data = await api('/api/commands') as { error?: string; categories?: Record<string, Array<{ name: string; desc: string; arg?: string; aliases?: string[] }>> };
|
|
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<string, CmdDef[]> = { '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<void> {
|
|
if (!args) { showToast('Usage: /model <model_name>'); 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<void> {
|
|
if (!args) { showToast('Usage: /workspace <name>'); 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, typeof skills> = {};
|
|
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<void> {
|
|
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 <name>' });
|
|
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)' ? ` <span class="cmd-item-arg">${esc(c.arg)}</span>` : '';
|
|
el.innerHTML = `<div class="cmd-item-name">/${esc(c.name)}${usage}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
|
|
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 };
|