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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user