Files
webui-develop/static/commands.ts

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 };