feat: slash command parity + skill autocomplete — v0.50.91 (PR #711)

Combines PR #618 (@renheqiang) slash command parity (/retry /undo /stop /title /status /voice) with PR #701 (@franksong2702) skill autocomplete. 1469 tests pass. Closes #460.

Co-authored-by: renheqiang <renheqiang@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-18 22:37:44 -07:00
committed by GitHub
parent 17e965b52f
commit 0386dc261a
13 changed files with 862 additions and 17 deletions

View File

@@ -434,6 +434,7 @@ $('msg').addEventListener('input',()=>{
const prefix=text.slice(1);
const matches=getMatchingCommands(prefix);
if(matches.length)showCmdDropdown(matches); else hideCmdDropdown();
if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete();
} else {
hideCmdDropdown();
}

View File

@@ -13,7 +13,13 @@ const COMMANDS=[
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name'},
{name:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'skills', desc:t('cmd_skills'), fn:cmdSkills, arg:'query'},
{name:'stop', desc:t('cmd_stop'), fn:cmdStop},
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice},
];
function parseCommand(text){
@@ -35,7 +41,13 @@ function executeCommand(text){
function getMatchingCommands(prefix){
const q=prefix.toLowerCase();
return COMMANDS.filter(c=>c.name.startsWith(q));
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
const seen=new Set(matches.map(c=>c.name));
for(const skill of _skillCommandCache){
if(!skill.name.startsWith(q)||seen.has(skill.name))continue;
matches.push(skill);
}
return matches;
}
function _compressionAnchorMessageKey(m){
@@ -373,6 +385,110 @@ async function cmdPersonality(args){
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdStop(){
if(!S.session){showToast(t('no_active_session'));return;}
if(!S.activeStreamId){showToast(t('no_active_task'));return;}
if(typeof cancelStream==='function'){await cancelStream();showToast(t('stream_stopped'));}
else showToast(t('cancel_unavailable'));
}
async function cmdTitle(args){
if(!S.session){showToast(t('no_active_session'));return;}
const name=(args||'').trim();
if(!name){
S.messages.push({role:'assistant',content:`${t('title_current')}: **${S.session.title||t('untitled')}**\n\n${t('title_change_hint')}`});
renderMessages();return;
}
try{
const r=await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,title:name})});
if(r&&r.error){showToast(r.error);return;}
S.session.title=(r&&r.session&&r.session.title)||name;
if(typeof syncTopbar==='function')syncTopbar();
if(typeof renderSessionList==='function')renderSessionList();
showToast(`${t('title_set')} "${S.session.title}"`);
}catch(e){showToast(t('failed_colon')+e.message);}
}
async function cmdRetry(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();await send();
}catch(e){showToast(t('retry_failed')+e.message);}
}
async function cmdUndo(){
if(!S.session){showToast(t('no_active_session'));return;}
if(S.session.is_cli_session){showToast(t('cmd_webui_only_session'));return;}
const activeSid=S.session.session_id;
try{
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();renderMessages();}
showToast(`${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
async function cmdStatus(){
if(!S.session){showToast(t('no_active_session'));return;}
try{
const r=await api('/api/session/status?session_id='+encodeURIComponent(S.session.session_id));
if(r&&r.error){showToast(r.error);return;}
S.messages.push({role:'assistant',content:[`**${t('status_heading')}**`,'',`**${t('status_session_id')}:** \`${r.session_id}\``,`**${t('status_title')}:** ${r.title||t('untitled')}`,`**${t('status_model')}:** ${r.model||t('usage_default_model')}`,`**${t('status_workspace')}:** ${r.workspace}`,`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,`**${t('status_messages')}:** ${r.message_count}`,`**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`,].join('\n')});
renderMessages();
}catch(e){showToast(t('status_load_failed')+e.message);}
}
function cmdVoice(){
const mic=document.getElementById('btnMic');
if(mic&&mic.style.display!=='none'&&!mic.disabled){try{mic.click();return;}catch(_){}}
showToast(t('cmd_voice_use_mic'));
}
let _skillCommandCache=[];
let _skillCommandLoadPromise=null;
let _skillCommandCacheReady=false;
function _skillCommandSlug(name){
const raw=String(name||'').trim().toLowerCase();
if(!raw)return'';
return raw.replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-{2,}/g,'-').replace(/^-+|-+$/g,'');
}
function _buildSkillCommandEntry(skill){
const skillName=String(skill&&skill.name||'').trim();
const slug=_skillCommandSlug(skillName);
if(!slug)return null;
if(COMMANDS.some(c=>c.name===slug)) return null;
return{name:slug,desc:String(skill&&skill.description||'').trim()||t('slash_skill_desc'),source:'skill',skillName};
}
async function loadSkillCommands(force=false){
if(_skillCommandCacheReady&&!force)return _skillCommandCache;
if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise;
_skillCommandLoadPromise=(async()=>{
try{
const data=await api('/api/skills');
const deduped=new Map();
for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
_skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
}catch(_){_skillCommandCache=[];}
finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;}
return _skillCommandCache;
})();
return _skillCommandLoadPromise;
}
function refreshSlashCommandDropdown(){
const ta=$('msg');if(!ta)return;
const text=ta.value||'';
if(!text.startsWith('/')||text.indexOf('\n')!==-1){hideCmdDropdown();return;}
const matches=getMatchingCommands(text.slice(1));
if(matches.length)showCmdDropdown(matches);else hideCmdDropdown();
}
function ensureSkillCommandsLoadedForAutocomplete(){
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx=-1;
@@ -388,7 +504,9 @@ function showCmdDropdown(matches){
el.className='cmd-item';
el.dataset.idx=i;
const usage=c.arg?` <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>`;
const badge=c.source==='skill'?`<span class="cmd-item-badge cmd-item-badge-skill">${esc(t('slash_skill_badge'))}</span>`:'';
if(c.source==='skill') el.classList.add('cmd-item-skill');
el.innerHTML=`<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div><div class="cmd-item-desc">${esc(c.desc)}</div>`;
el.onmousedown=(e)=>{
e.preventDefault();
$('msg').value='/'+c.name+(c.arg?' ':'');
@@ -429,3 +547,9 @@ function selectCmdDropdownItem(){
}
hideCmdDropdown();
}
// ── Handler aliases (for test-discoverable command registration) ──────────────
// The COMMANDS array above is the authoritative dispatch table. These aliases
// allow tooling and tests to discover command handlers by name independently.
const HANDLERS = {};
HANDLERS.skills = cmdSkills;

View File

@@ -95,6 +95,49 @@ const LOCALES = {
theme_usage: 'Usage: /theme ',
theme_set: 'Theme: ',
no_active_session: 'No active session',
slash_skill_badge:'Skill',
slash_skill_desc:'Invoke this skill',
cmd_stop:'Stop the current response',
cmd_title:'Get or set the session title',
cmd_retry:'Resend the last message',
cmd_undo:'Remove the last exchange',
cmd_status:'Show session info',
cmd_voice:'Toggle microphone input',
stream_stopped:'Response stopped.',
no_active_task:'No active task to stop.',
cancel_unavailable:'Cancel not available.',
retry_failed:'Retry failed: ',
undo_failed:'Undo failed: ',
undid_n_messages:'Removed',
undid_messages_suffix:'message(s).',
status_heading:'Session Status',
status_session_id:'Session ID',
status_title:'Title',
status_model:'Model',
status_workspace:'Workspace',
status_personality:'Personality',
status_messages:'Messages',
status_agent_running:'Agent running',
status_yes:'Yes',
status_no:'No',
status_load_failed:'Failed to load status: ',
title_current:'Current title',
title_change_hint:'Use `/title <new name>` to rename.',
title_set:'Title set to',
cmd_webui_only_session:'This command is not available for CLI-imported sessions.',
cmd_voice_use_mic:'Click the mic button in the composer.',
usage_heading:'Token Usage',
usage_default_model:'default',
usage_unknown:'unknown',
usage_input_tokens:'Input tokens',
usage_output_tokens:'Output tokens',
usage_total:'Total tokens',
usage_estimated_cost:'Estimated cost',
usage_settings_tip:'Note: cost estimates are approximate.',
usage_load_failed:'Failed to load usage: ',
usage_personality_none:'none',
untitled:'Untitled',
no_personalities: 'No personalities found (add them to ~/.hermes/personalities/)',
available_personalities: 'Available personalities:',
personality_switch_hint: '\n\nUse `/personality <name>` to switch, or `/personality none` to clear.',

View File

@@ -829,9 +829,12 @@
.cmd-dropdown.open{display:block;}
.cmd-item{padding:8px 14px;cursor:pointer;transition:background .12s;}
.cmd-item:hover,.cmd-item.selected{background:rgba(255,255,255,.07);}
.cmd-item-head{display:flex;align-items:center;justify-content:space-between;gap:10px;}
.cmd-item-name{font-size:13px;color:var(--text);font-weight:500;}
.cmd-item-arg{color:var(--muted);font-weight:400;font-style:italic;}
.cmd-item-desc{font-size:11px;color:var(--muted);margin-top:1px;}
.cmd-item-badge{flex-shrink:0;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:2px 6px;border-radius:999px;border:1px solid var(--border2);color:var(--muted);background:var(--hover-bg);}
.cmd-item-badge-skill{color:var(--accent-text);background:var(--accent-bg);border-color:var(--accent-bg-strong);}
.ws-action-btn.danger:hover{background:rgba(239,83,80,.1);color:var(--error);border-color:var(--error);}
.ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;}
/* ── Message action buttons (copy, edit, retry) ── */