diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dca22e..7ba270e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.113] — 2026-04-20 + +### Fixed +- **Slash autocomplete now keeps command completion flowing into sub-arguments** — sub-argument-only commands like `/reasoning` now appear in the first suggestion list, the current dropdown selection is visibly highlighted while navigating with arrow keys, and accepting a top-level command like `/reasoning` immediately opens the second-level suggestions instead of requiring an extra space press. (Fixes #632, credit: @franksong2702) + ## [v0.50.112] — 2026-04-20 ### Added diff --git a/static/boot.js b/static/boot.js index 8f03302..5280098 100644 --- a/static/boot.js +++ b/static/boot.js @@ -454,9 +454,16 @@ $('msg').addEventListener('input',()=>{ updateSendBtn(); const text=$('msg').value; if(text.startsWith('/')&&text.indexOf('\n')===-1){ - const prefix=text.slice(1); - const matches=getMatchingCommands(prefix); - if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); + if(typeof getSlashAutocompleteMatches==='function'){ + getSlashAutocompleteMatches(text).then(matches=>{ + if(($('msg').value||'')!==text) return; + if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); + }); + }else{ + const prefix=text.slice(1); + const matches=getMatchingCommands(prefix); + if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); + } if(typeof ensureSkillCommandsLoadedForAutocomplete==='function') ensureSkillCommandsLoadedForAutocomplete(); } else { hideCmdDropdown(); diff --git a/static/commands.js b/static/commands.js index 80a09c5..e036311 100644 --- a/static/commands.js +++ b/static/commands.js @@ -7,12 +7,12 @@ const COMMANDS=[ {name:'clear', desc:t('cmd_clear'), fn:cmdClear}, {name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]'}, {name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact}, - {name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name'}, + {name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models'}, {name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name'}, {name:'new', desc:t('cmd_new'), fn:cmdNew}, {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:'personality', desc:t('cmd_personality'), fn:cmdPersonality, arg:'name', subArgs:'personalities'}, {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]'}, @@ -22,6 +22,12 @@ const COMMANDS=[ {name:'voice', desc:t('cmd_voice'), fn:cmdVoice}, ]; +const SLASH_SUBARG_SOURCES={ + model:{desc:t('cmd_model'), subArgs:'models'}, + personality:{desc:t('cmd_personality'), subArgs:'personalities'}, + reasoning:{desc:'Set reasoning effort', subArgs:['low','medium','high']}, +}; + function parseCommand(text){ if(!text.startsWith('/'))return null; const parts=text.slice(1).split(/\s+/); @@ -43,13 +49,137 @@ function getMatchingCommands(prefix){ const q=prefix.toLowerCase(); 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 [name, spec] of Object.entries(SLASH_SUBARG_SOURCES)){ + if(!name.startsWith(q)||seen.has(name))continue; + matches.push({ + name, + desc:spec.desc, + arg:'name', + source:'subarg-command', + }); + seen.add(name); + } for(const skill of _skillCommandCache){ if(!skill.name.startsWith(q)||seen.has(skill.name))continue; matches.push(skill); + seen.add(skill.name); } return matches; } +let _slashModelCache=null; +let _slashModelCachePromise=null; +let _slashPersonalityCache=null; +let _slashPersonalityCachePromise=null; + +function _normalizeSlashSubArg(value){ + return String(value||'').trim(); +} + +function _getSlashModelSubArgsFromDom(){ + const sel=$('modelSelect'); + if(!sel) return []; + const values=[]; + for(const opt of Array.from(sel.options||[])){ + const value=_normalizeSlashSubArg(opt.value||opt.textContent||''); + if(value) values.push(value); + } + return Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b)); +} + +async function _loadSlashModelSubArgs(force=false){ + const domValues=_getSlashModelSubArgsFromDom(); + if(domValues.length&&!force){ + _slashModelCache=domValues; + return domValues; + } + if(_slashModelCache&&!force) return _slashModelCache; + if(_slashModelCachePromise&&!force) return _slashModelCachePromise; + _slashModelCachePromise=(async()=>{ + try{ + const data=await api('/api/models'); + const values=[]; + for(const group of (data&&data.groups)||[]){ + for(const model of (group&&group.models)||[]){ + const id=_normalizeSlashSubArg(model&&model.id); + if(id) values.push(id); + } + } + const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b)); + _slashModelCache=deduped; + return deduped; + }catch(_){ + _slashModelCache=domValues; + return domValues; + }finally{ + _slashModelCachePromise=null; + } + })(); + return _slashModelCachePromise; +} + +async function _loadSlashPersonalitySubArgs(force=false){ + if(_slashPersonalityCache&&!force) return _slashPersonalityCache; + if(_slashPersonalityCachePromise&&!force) return _slashPersonalityCachePromise; + _slashPersonalityCachePromise=(async()=>{ + try{ + const data=await api('/api/personalities'); + const values=['none']; + for(const p of (data&&data.personalities)||[]){ + const name=_normalizeSlashSubArg(p&&p.name); + if(name) values.push(name); + } + const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b)); + _slashPersonalityCache=deduped; + return deduped; + }catch(_){ + _slashPersonalityCache=['none']; + return _slashPersonalityCache; + }finally{ + _slashPersonalityCachePromise=null; + } + })(); + return _slashPersonalityCachePromise; +} + +function _getSlashSubArgOptions(spec){ + if(Array.isArray(spec)) return Promise.resolve(spec.slice()); + if(spec==='models') return _loadSlashModelSubArgs(); + if(spec==='personalities') return _loadSlashPersonalitySubArgs(); + return Promise.resolve([]); +} + +function _parseSlashAutocomplete(text){ + if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null; + const raw=text.slice(1); + const hasSpace=/\s/.test(raw); + const parts=raw.split(/\s+/); + const cmdName=(parts[0]||'').toLowerCase(); + const command=COMMANDS.find(c=>c.name===cmdName); + const subArgSource=(command&&command.subArgs)?command:SLASH_SUBARG_SOURCES[cmdName]; + if(!hasSpace||!subArgSource){ + return {kind:'commands', query:raw}; + } + const argText=raw.slice(cmdName.length).replace(/^\s+/,''); + return {kind:'subargs', command:{name:cmdName, desc:subArgSource.desc, subArgs:subArgSource.subArgs}, query:argText.toLowerCase(), rawQuery:argText}; +} + +async function getSlashAutocompleteMatches(text){ + const parsed=_parseSlashAutocomplete(text); + if(!parsed) return []; + if(parsed.kind==='commands') return getMatchingCommands(parsed.query); + const options=await _getSlashSubArgOptions(parsed.command.subArgs); + return options + .filter(opt=>String(opt).toLowerCase().startsWith(parsed.query)) + .map(opt=>({ + name:parsed.command.name, + value:String(opt), + desc:parsed.command.desc, + source:'subarg', + parent:parsed.command.name, + })); +} + function _compressionAnchorMessageKey(m){ if(!m||!m.role||m.role==='tool') return null; let content=''; @@ -481,8 +611,10 @@ 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(); + getSlashAutocompleteMatches(text).then(matches=>{ + if(($('msg').value||'')!==text) return; + if(matches.length)showCmdDropdown(matches);else hideCmdDropdown(); + }); } function ensureSkillCommandsLoadedForAutocomplete(){ if(_skillCommandCacheReady||_skillCommandLoadPromise)return; @@ -497,21 +629,36 @@ function showCmdDropdown(matches){ const dd=$('cmdDropdown'); if(!dd)return; dd.innerHTML=''; - _cmdSelectedIdx=-1; + _cmdSelectedIdx=matches.length?0:-1; for(let i=0;i${esc(c.arg)}`:''; + const isSubArg=c.source==='subarg'; + const usage=(!isSubArg&&c.arg)?` ${esc(c.arg)}`:''; const badge=c.source==='skill'?`${esc(t('slash_skill_badge'))}`:''; if(c.source==='skill') el.classList.add('cmd-item-skill'); - el.innerHTML=`
/${esc(c.name)}${usage}${badge}
${esc(c.desc)}
`; + const nameHtml=isSubArg + ? `
/${esc(c.parent)} ${esc(c.value)}
` + : `
/${esc(c.name)}${usage}${badge}
`; + const descHtml=`
${esc(c.desc)}
`; + el.innerHTML=`${nameHtml}${descHtml}`; el.onmousedown=(e)=>{ e.preventDefault(); - $('msg').value='/'+c.name+(c.arg?' ':''); - hideCmdDropdown(); + const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':'')); + $('msg').value=nextValue; $('msg').focus(); + if(!isSubArg&&c.source!=='skill'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){ + getSlashAutocompleteMatches(nextValue).then(matches=>{ + if(($('msg').value||'')!==nextValue) return; + if(matches.length) showCmdDropdown(matches); + else hideCmdDropdown(); + }); + }else{ + hideCmdDropdown(); + } }; dd.appendChild(el); } diff --git a/static/style.css b/static/style.css index 122c33f..1a37b90 100644 --- a/static/style.css +++ b/static/style.css @@ -830,9 +830,12 @@ .cmd-dropdown{display:none;position:absolute;left:0;right:0;bottom:calc(100% + 4px);width:auto;max-width:100%;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -8px 24px rgba(0,0,0,.4);z-index:200;max-height:240px;overflow-y:auto;} .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:hover{background:rgba(255,255,255,.07);} +.cmd-item.selected{background:var(--accent-bg);outline:1px solid var(--accent-bg-strong);} .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-parent{color:var(--muted);font-weight:400;} +.cmd-item-subarg{font-weight:600;} .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);} diff --git a/tests/test_issue632.py b/tests/test_issue632.py new file mode 100644 index 0000000..dde8b73 --- /dev/null +++ b/tests/test_issue632.py @@ -0,0 +1,53 @@ +""" +Issue #632: slash autocomplete should suggest second-level arguments. + +Covers: +- commands.js exposes a dedicated slash autocomplete parser/loader +- /model sub-args hydrate from /api/models +- /personality sub-args hydrate from /api/personalities +- /reasoning provides static low/medium/high suggestions without becoming a + locally executed built-in command +- boot.js uses the async slash autocomplete helper while typing +""" +import pathlib + + +REPO_ROOT = pathlib.Path(__file__).parent.parent +COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8") +BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8") +STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") + + +def test_subarg_registry_exists_without_promoting_reasoning_to_builtin(): + assert "const SLASH_SUBARG_SOURCES=" in COMMANDS_JS + assert "reasoning:{desc:'Set reasoning effort', subArgs:['low','medium','high']}" in COMMANDS_JS + assert "{name:'reasoning'" not in COMMANDS_JS, \ + "/reasoning suggestions must not register as a local built-in command" + assert "source:'subarg-command'" in COMMANDS_JS, \ + "top-level autocomplete should still surface subarg-only commands like /reasoning" + + +def test_model_and_personality_subargs_load_from_existing_apis(): + assert "_loadSlashModelSubArgs" in COMMANDS_JS + assert "api('/api/models')" in COMMANDS_JS + assert "_loadSlashPersonalitySubArgs" in COMMANDS_JS + assert "api('/api/personalities')" in COMMANDS_JS + + +def test_slash_autocomplete_parses_second_level_arguments(): + assert "function _parseSlashAutocomplete" in COMMANDS_JS + assert "return {kind:'subargs'" in COMMANDS_JS + assert "getSlashAutocompleteMatches" in COMMANDS_JS + + +def test_boot_uses_async_slash_autocomplete_helper(): + assert "getSlashAutocompleteMatches(text).then(matches=>" in BOOT_JS + + +def test_subarg_dropdown_has_distinct_parent_and_argument_styling(): + assert ".cmd-item-parent" in STYLE_CSS + assert ".cmd-item-subarg" in STYLE_CSS + assert ".cmd-item.selected{background:var(--accent-bg);" in STYLE_CSS + assert "_cmdSelectedIdx=matches.length?0:-1;" in COMMANDS_JS + assert "getSlashAutocompleteMatches(nextValue).then(matches=>" in COMMANDS_JS, \ + "selecting a first-level command with sub-args should immediately open second-level suggestions"