Files
hermes-webui/static/commands.js
T
nesquena-hermes a2b793be4f fix(picker): Nous Portal featured-set cap + endpoint symmetry (closes #1567)
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.

#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.

Two structural causes:

1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
   regardless of authentication state and unconditionally live-fetches
   the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
   providers in ``detected_providers``, gated on
   ``hermes_cli.models.list_available_providers().authenticated``.
   That flag can disagree with ``get_auth_status(<id>).logged_in`` on
   some hermes_cli versions.

When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.

Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.

UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:

- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
  selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
  understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
  header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
  pill list (only fires for non-OAuth providers — OAuth cards never
  render pills) with a tooltip pointing at the slash command

Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:

1. Always include the user's currently-selected model (sticky — no
   orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
   ``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
   (one model per vendor each pass) so no vendor monopolises the slots

Changes by file
===============

api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
  `_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
  `_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
  - Apply featured-set cap with sticky-selection signal
  - Return `extra_models` alongside `models` for the catalog tail
  - Decorate optgroup label with truncation count
  - Drop stale-4 fallback when authenticated but live-fetch empty
    (omit the group entirely; truth lives in the providers card and
    the next cache rebuild will heal it)
  - Keep stale-4 fallback when hermes_cli is unavailable (test envs,
    package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
  check after the existing `list_available_providers()` loop, so the
  picker matches the providers card on hermes_cli versions where the
  two signals disagree

api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
  frontend for the "N models · OAuth" header text)

api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
  enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
  endpoint's logic

static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
  outside the visible dropdown still renders with its proper label

static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
  full catalog (not just the trimmed featured slice)

static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
  `p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
  models_total` with a tooltip pointing at the slash command

static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
  cursor:help, no select) — visually distinct from real model pills

Tests
=====

`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):

- TestBuildNousFeaturedSet (8): unit tests on the helper —
  small-catalog no-op, large-catalog cap to target, disjoint+complete
  invariants, priority-vendor round-robin guarantee, sticky selection
  with and without `@nous:` prefix, curated-flagship preservation,
  empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
  on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
  `get_auth_status` agrees but `list_available_providers` disagrees;
  picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
  hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
  exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
  the JS contract for `extra_models`, `models_total`, and the "+N more"
  disclosure

Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
  15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
  _dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
  slice as expected

4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.

Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist
2026-05-03 21:44:22 +00:00

1102 lines
46 KiB
JavaScript

// ── Slash commands ──────────────────────────────────────────────────────────
// Built-in commands intercepted before send(). Each command runs locally
// (no round-trip to the agent) and shows feedback via toast or local message.
const COMMANDS=[
// noEcho:true = action-only commands that don't produce a chat response.
// Commands without noEcho get a user message echoed to the chat (#840).
{name:'help', desc:t('cmd_help'), fn:cmdHelp},
{name:'clear', desc:t('cmd_clear'), fn:cmdClear, noEcho:true},
{name:'compress', desc:t('cmd_compress'), fn:cmdCompress, arg:'[focus topic]', noEcho:true},
{name:'compact', desc:t('cmd_compact_alias'), fn:cmdCompact, noEcho:true},
{name:'model', desc:t('cmd_model'), fn:cmdModel, arg:'model_name', subArgs:'models', noEcho:true},
{name:'workspace', desc:t('cmd_workspace'), fn:cmdWorkspace, arg:'name', noEcho:true},
{name:'terminal', desc:t('cmd_terminal'), fn:cmdTerminal, noEcho:true},
{name:'new', desc:t('cmd_new'), fn:cmdNew, noEcho:true},
{name:'usage', desc:t('cmd_usage'), fn:cmdUsage, noEcho:true},
{name:'theme', desc:t('cmd_theme'), fn:cmdTheme, arg:'name', noEcho:true},
{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, noEcho:true},
{name:'queue', desc:t('cmd_queue'), fn:cmdQueue, arg:'message', noEcho:true},
{name:'interrupt', desc:t('cmd_interrupt'), fn:cmdInterrupt, arg:'message', noEcho:true},
{name:'steer', desc:t('cmd_steer'), fn:cmdSteer, arg:'message', noEcho:true},
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
{name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true},
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
{name:'yolo', desc:t('cmd_yolo'), fn:cmdYolo, noEcho:true},
{name:'branch', desc:t('cmd_branch'), fn:cmdBranch, arg:'[name]', noEcho:true},
];
const SLASH_SUBARG_SOURCES={
model:{desc:t('cmd_model'), subArgs:'models'},
personality:{desc:t('cmd_personality'), subArgs:'personalities'},
};
function parseCommand(text){
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){
const parsed=parseCommand(text);
if(!parsed)return null;
const cmd=COMMANDS.find(c=>c.name===parsed.name);
if(!cmd)return null;
// A handler may return `false` to opt out of interception — e.g. /reasoning
// with an effort level falls through so the agent's own handler sees it,
// preserving the pre-existing pass-through behaviour for that subcommand.
if(cmd.fn(parsed.args)===false)return null;
// Return noEcho flag so send() knows whether to echo the command as a user message (#840).
return {noEcho:!!cmd.noEcho};
}
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;
let _agentCommandCache=null;
let _agentCommandCachePromise=null;
// Invalidate the /api/models slash-suggestion cache. Called by panels.js
// after a provider is added or removed so the next /model autocomplete
// rebuilds from a fresh /api/models response (#1539). Returning a function
// rather than letting callers poke the module-local lets/promises directly
// keeps the cache shape encapsulated to this module.
function _invalidateSlashModelCache(){
_slashModelCache=null;
_slashModelCachePromise=null;
}
// Expose on window when available. Guarded by typeof so the module is
// importable in headless test contexts (vm.runInContext) that don't
// define a window global — see tests/test_cli_only_slash_commands.py.
if(typeof window!=='undefined'){
window._invalidateSlashModelCache=_invalidateSlashModelCache;
}
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);
}
// Include extra_models (the catalog tail that doesn't render as
// <option> entries when the picker is capped) so /model autocomplete
// covers the full catalog. The trimming is purely a dropdown
// scannability concern — the slash command exists precisely so
// power users can reach any model by typing its name. #1567.
for(const model of (group&&group.extra_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([]);
}
async function loadAgentCommandMetadata(force=false){
if(_agentCommandCache&&!force) return _agentCommandCache;
if(_agentCommandCachePromise&&!force) return _agentCommandCachePromise;
_agentCommandCachePromise=(async()=>{
try{
const data=await api('/api/commands');
_agentCommandCache=Array.isArray(data&&data.commands)?data.commands:[];
}catch(_){
_agentCommandCache=[];
}finally{
_agentCommandCachePromise=null;
}
return _agentCommandCache;
})();
return _agentCommandCachePromise;
}
async function getAgentCommandMetadata(name){
const needle=String(name||'').trim().toLowerCase();
if(!needle) return null;
const commands=await loadAgentCommandMetadata();
return commands.find(cmd=>{
if(String(cmd&&cmd.name||'').toLowerCase()===needle) return true;
return Array.isArray(cmd&&cmd.aliases)&&cmd.aliases.some(a=>String(a||'').toLowerCase()===needle);
})||null;
}
function cliOnlyCommandResponse(cmdName, meta){
const name=String((meta&&meta.name)||cmdName||'').trim();
const desc=String((meta&&meta.description)||'').trim();
const detail=desc?`\n\n${desc}`:'';
let extra='';
if(name==='browser'){
extra='\n\nBrowser tools in WebUI must be configured server-side with the agent/browser environment. Once configured, ask the model to use browser tools directly; `/browser` itself only works in `hermes chat`.';
}
return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`;
}
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='';
try{
content=typeof msgContent==='function' ? String(msgContent(m)||'') : String(m.content||'');
}catch(_){
content=String(m.content||'');
}
const norm=content.replace(/\s+/g,' ').trim().slice(0,160);
const ts=m._ts||m.timestamp||null;
const attachments=Array.isArray(m.attachments)?m.attachments.length:0;
if(!norm && !attachments && !ts) return null;
return {role:String(m.role||''), ts, text:norm, attachments};
}
// ── Command handlers ────────────────────────────────────────────────────────
function cmdHelp(){
const lines=COMMANDS.map(c=>{
const usage=c.arg ? (String(c.arg).startsWith('[') ? ` ${c.arg}` : ` <${c.arg}>`) : '';
return ` /${c.name}${usage}${c.desc}`;
});
const msg={role:'assistant',content:t('available_commands')+'\n'+lines.join('\n')};
S.messages.push(msg);
renderMessages();
showToast(t('type_slash'));
}
function cmdClear(){
if(!S.session)return;
S.messages=[];S.toolCalls=[];
clearLiveToolCards();
if(typeof clearCompressionUi==='function') clearCompressionUi();
renderMessages();
$('emptyState').style.display='';
showToast(t('conversation_cleared'));
}
async function cmdModel(args){
if(!args){showToast(t('model_usage'));return;}
const sel=$('modelSelect');
if(!sel)return;
const q=args.toLowerCase();
// Fuzzy match: find first option whose label or value contains the query
let match=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(t('no_model_match')+`"${args}"`);return;}
sel.value=match;
await sel.onchange();
showToast(t('switched_to')+match);
}
async function cmdWorkspace(args){
if(!args){showToast(t('workspace_usage'));return;}
try{
const data=await api('/api/workspaces');
const q=args.toLowerCase();
const ws=(data.workspaces||[]).find(w=>
(w.name||'').toLowerCase().includes(q)||w.path.toLowerCase().includes(q)
);
if(!ws){showToast(t('no_workspace_match')+`"${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){showToast(t('workspace_switch_failed')+e.message);}
}
async function cmdTerminal(){
if(!S.session&&typeof newSession==='function'){
if(!S._profileSwitchWorkspace&&!S._profileDefaultWorkspace){
try{
const data=await api('/api/workspaces');
const first=(data.workspaces||[])[0];
S._profileSwitchWorkspace=data.last||(first&&first.path)||null;
}catch(_){}
}
await newSession();
if(typeof renderSessionList==='function') await renderSessionList();
}
if(!S.session||!S.session.workspace){
showToast(t('terminal_no_workspace_title'),2600,'warning');
if(typeof syncTerminalButton==='function') syncTerminalButton();
return;
}
if(typeof toggleComposerTerminal==='function') await toggleComposerTerminal(true);
}
async function cmdNew(){
if(typeof clearCompressionUi==='function') clearCompressionUi();
await newSession();
await renderSessionList();
$('msg').focus();
showToast(t('new_session'));
}
async function _runManualCompression(focusTopic){
if(!S.session){showToast(t('no_active_session'));return;}
let visibleCount=0;
try{
const sid=S.session.session_id;
// Preflight: verify the viewed session still exists before compressing.
// This avoids a confusing "not found" toast when the UI is stale.
try{
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
if(!live||!live.session||live.session.session_id!==sid){
throw new Error('session no longer available');
}
S.session=live.session;
S.messages=live.session.messages||[];
S.toolCalls=live.session.tool_calls||[];
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false;
}catch(preflightErr){
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+(preflightErr.message||'session no longer available'));
return;
}
if(typeof setBusy==='function') setBusy(true);
const body={session_id:sid};
if(focusTopic) body.focus_topic=focusTopic;
const visibleMessages=(S.messages||[]).filter(m=>{
if(!m||!m.role||m.role==='tool') return false;
if(m.role==='assistant'){
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(hasTc||hasTu|| (typeof _messageHasReasoningPayload==='function' && _messageHasReasoningPayload(m))) return true;
}
return typeof msgContent==='function' ? !!msgContent(m) || !!m.attachments?.length : !!m.content || !!m.attachments?.length;
});
visibleCount=visibleMessages.length;
const anchorVisibleIdx=Math.max(0, visibleCount - 1);
const anchorMessageKey=_compressionAnchorMessageKey(visibleMessages[visibleMessages.length-1]||null);
const commandText=focusTopic?`/compress ${focusTopic}`:'/compress';
if(typeof setCompressionUi==='function'){
setCompressionUi({
sessionId:S.session.session_id,
phase:'running',
focusTopic:focusTopic||'',
commandText,
beforeCount:visibleCount,
anchorVisibleIdx,
anchorMessageKey,
});
}
if(typeof setComposerStatus==='function') setComposerStatus(t('compressing'));
renderMessages();
const data=await api('/api/session/compress',{method:'POST',body:JSON.stringify(body)});
if(data&&data.session){
const currentSid=S.session&&S.session.session_id;
if(data.session.session_id&&data.session.session_id!==currentSid){
await loadSession(data.session.session_id);
}else{
S.session=data.session;
S.messages=data.session.messages||[];
S.toolCalls=data.session.tool_calls||[];
clearLiveToolCards();
localStorage.setItem('hermes-webui-session',S.session.session_id);
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
syncTopbar();
renderMessages();
await renderSessionList();
updateQueueBadge(S.session.session_id);
}
}
const summary=data&&data.summary;
if(typeof setCompressionUi==='function'&&S.session){
const referenceMsg=(S.messages||[]).find(m=>typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(m));
const messageRef=referenceMsg?msgContent(referenceMsg)||String(referenceMsg.content||''):'';
const summaryRef=summary&&typeof summary.reference_message==='string' ? String(summary.reference_message||'').trim() : '';
// Prefer the persisted compaction handoff when it already exists in session state.
// The short summary fallback is only for environments where that message is unavailable.
const referenceText=messageRef || summaryRef;
const effectiveFocus=(data&&data.focus_topic)||focusTopic||'';
setCompressionUi({
sessionId:S.session.session_id,
phase:'done',
focusTopic:effectiveFocus,
commandText:effectiveFocus?`/compress ${effectiveFocus}`:'/compress',
beforeCount:visibleCount,
summary:summary||null,
referenceText,
anchorVisibleIdx: data?.session?.compression_anchor_visible_idx,
anchorMessageKey: data?.session?.compression_anchor_message_key||null,
});
}
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
}catch(e){
if(typeof setCompressionUi==='function'){
const currentSid=S.session&&S.session.session_id;
setCompressionUi({
sessionId:currentSid||'',
phase:'error',
focusTopic:(focusTopic||'').trim(),
commandText:focusTopic?`/compress ${focusTopic}`:'/compress',
beforeCount:(S.messages||[]).filter(m=>m&&m.role&&m.role!=='tool').length,
errorText:`Compression failed: ${e.message}`,
anchorVisibleIdx: Math.max(0, visibleCount - 1),
anchorMessageKey:null,
});
}
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
if(typeof setBusy==='function') setBusy(false);
if(typeof setComposerStatus==='function') setComposerStatus('');
renderMessages();
showToast('Compression failed: '+e.message);
return;
}
if(typeof setBusy==='function') setBusy(false);
}
async function cmdCompress(args){
await _runManualCompression((args||'').trim());
}
async function cmdCompact(args){
await _runManualCompression((args||'').trim());
}
async function cmdUsage(){
const next=!window._showTokenUsage;
window._showTokenUsage=next;
try{
await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})});
}catch(e){}
// Update the settings checkbox if the panel is open
const cb=$('settingsShowTokenUsage');
if(cb) cb.checked=next;
renderMessages();
showToast(next?t('token_usage_on'):t('token_usage_off'));
}
async function cmdTheme(args){
const themes=['system','dark','light'];
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
const val=(args||'').toLowerCase().trim();
// Check if it's a theme
if(themes.includes(val)||legacyThemes.includes(val)){
const appearance=_normalizeAppearance(
val,
legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin')
);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsTheme');
if(sel)sel.value=appearance.theme;
const skinSel=$('settingsSkin');
if(skinSel)skinSel.value=appearance.skin;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:''));
return;
}
// Check if it's a skin
if(skins.includes(val)){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsSkin');
if(sel)sel.value=appearance.skin;
const themeSel=$('settingsTheme');
if(themeSel)themeSel.value=appearance.theme;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.skin);
return;
}
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
}
async function cmdSkills(args){
try{
const data = await api('/api/skills');
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', content: args ? `No skills matching "${args}".` : 'No skills found.'};
S.messages.push(msg); renderMessages(); return;
}
// Group by category
const byCategory = {};
skills.forEach(s => {
const cat = s.category || 'General';
if(!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(s);
});
const lines = [];
for(const [cat, items] of Object.entries(byCategory).sort()){
lines.push(`**${cat}**`);
items.forEach(s => {
const desc = s.description ? `${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', content: header + lines.join('\n')});
renderMessages();
showToast(t('type_slash'));
}catch(e){
showToast('Failed to load skills: '+e.message);
}
}
async function cmdPersonality(args){
if(!S.session){showToast(t('no_active_session'));return;}
if(!args){
// List available personalities
try{
const data=await api('/api/personalities');
if(!data.personalities||!data.personalities.length){
showToast(t('no_personalities'));
return;
}
const list=data.personalities.map(p=>` **${p.name}**${p.description?' — '+p.description:''}`).join('\n');
S.messages.push({role:'assistant',content:t('available_personalities')+'\n\n'+list+t('personality_switch_hint')});
renderMessages();
}catch(e){showToast(t('personalities_load_failed'));}
return;
}
const name=args.trim();
if(name.toLowerCase()==='none'||name.toLowerCase()==='default'||name.toLowerCase()==='clear'){
try{
await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name:''})});
showToast(t('personality_cleared'));
}catch(e){showToast(t('failed_colon')+e.message);}
return;
}
try{
const res=await api('/api/personality/set',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,name})});
S.messages.push({role:'assistant',content:t('personality_set')+`**${name}**`});
renderMessages();
showToast(t('personality_set')+name);
}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'));
}
// ── Busy-input mode commands ──────────────────────────────────────────────
// These commands let users override the default busy_input_mode setting for a
// specific message. They are only meaningful while the agent is running.
/**
* /queue <message> — Explicitly queue a message for the next turn.
* Works regardless of the busy_input_mode setting.
*/
async function cmdQueue(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_queue_no_msg'));return;}
// If nothing is running, /queue <msg> just sends like a normal message
if(!S.busy){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
showToast(t('cmd_queue_confirm'),2000);
}
/**
* /interrupt <message> — Cancel the current turn and send a new message.
* Calls cancelStream() then queues the message so the drain picks it up.
*/
async function cmdInterrupt(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_interrupt_no_msg'));return;}
// If nothing is running, /interrupt <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
// Queue the message first (before cancel sets busy=false and drains)
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
// Cancel the active stream; setBusy(false) will drain the queue
if(typeof cancelStream==='function'){await cancelStream();}
showToast(t('cmd_interrupt_confirm'),2000);
}
/**
* /steer <message> — Inject a steering hint mid-task without interrupting.
*
* Calls POST /api/chat/steer which looks up the cached AIAgent for this
* session and calls agent.steer(text). The agent's run loop appends the
* steer text to the next tool-result message so the model sees it on its
* next iteration — same pathway as the CLI's /steer command.
*
* Falls back to interrupt mode when the agent isn't running, isn't cached,
* or doesn't support steer (older hermes-agent versions).
*/
async function cmdSteer(args){
const msg=(args||'').trim();
if(!msg){showToast(t('cmd_steer_no_msg'));return;}
// If nothing is running, /steer <msg> just sends like a normal message
if(!S.busy||!S.activeStreamId){
const inp=$('msg');
if(inp){inp.value=msg;}
if(typeof send==='function'){await send();}
return;
}
if(!S.session){showToast(t('no_active_session'));return;}
await _trySteer(msg, /*explicitSteer=*/true);
}
/**
* Shared implementation for /steer and the busy_input_mode='steer' path.
*
* Tries the real steer endpoint first. On any non-accept response (no cached
* agent, agent lacks steer, stream dead, etc.) falls back to interrupt+queue:
* queues the message and cancels the stream so the drain re-sends it.
*
* @param {string} msg - The steer text.
* @param {boolean} explicitSteer - True if the user explicitly invoked /steer
* (vs the busy-mode auto-fallback). Affects toast wording only.
*/
async function _trySteer(msg, explicitSteer){
let result=null;
try{
result=await api('/api/chat/steer',{
method:'POST',
body:JSON.stringify({session_id:S.session.session_id,text:msg}),
});
}catch(e){
// Network or server error — fall back to interrupt
result={accepted:false, fallback:'network_error'};
}
if(result&&result.accepted){
showToast(t('cmd_steer_delivered'),2500);
return;
}
// Fall back to interrupt: queue the message + cancel the stream so the
// drain in setBusy(false) re-sends it as a fresh turn.
queueSessionMessage(S.session.session_id,{text:msg,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',profile:S.activeProfile||'default'});
updateQueueBadge(S.session.session_id);
S.pendingFiles=[];renderTray();
if(typeof cancelStream==='function'){await cancelStream();}
// Toast wording differs based on why we're falling back so the user
// understands what just happened.
const reason=(result&&result.fallback)||'unknown';
if(explicitSteer){
showToast(t('cmd_steer_fallback'),2500);
} else if(reason==='no_cached_agent'||reason==='not_running'||reason==='stream_dead'){
// Busy mode hit the steer path before the agent was ready —
// interrupt is the natural fallback, no need to call out steer.
showToast(t('busy_interrupt_confirm'),2000);
} else {
showToast(t('busy_steer_fallback'),2500);
}
}
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}"`);
S.messages.push({role:'assistant',content:`${t('title_set')} **${S.session.title}**`});
renderMessages();
}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();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;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();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
showToast(`${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
async function undoLastExchange(){await cmdUndo();}
async function cmdBtw(args){
if(!S.session){showToast(t('no_active_session'));return;}
const question=(args||'').trim();
if(!question){showToast(t('cmd_btw_usage'));return;}
showToast(t('btw_asking'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})});
if(r&&r.error){showToast(r.error);return;}
// Connect to the ephemeral SSE stream
const streamId=r.stream_id;
const parentSid=r.parent_session_id;
if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question);
}catch(e){showToast(t('btw_failed')+e.message);}
}
async function cmdBackground(args){
if(!S.session){showToast(t('no_active_session'));return;}
const prompt=(args||'').trim();
if(!prompt){showToast(t('cmd_background_usage'));return;}
showToast(t('bg_running'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})});
if(r&&r.error){showToast(r.error);return;}
// Show background badge and start polling
if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id);
if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt);
}catch(e){showToast(t('bg_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;}
// Build status card lines matching CLI /status output
const provider=window._activeProvider||'';
const profile=r.profile||S.activeProfile||'default';
const started=r.created_at?new Date(r.created_at).toLocaleString():t('status_unknown');
const fmtNum=n=>typeof n==='number'?n.toLocaleString():'0';
const tokens=r.total_tokens?`${fmtNum(r.input_tokens)} in / ${fmtNum(r.output_tokens)} out`:t('status_no_tokens');
const cost=r.estimated_cost?` (~$${Number(r.estimated_cost).toFixed(4)})`:'';
const lines=[
`**${t('status_heading')}**`,'',
`\`${r.session_id}\``,'',
`**${t('status_title')}:** ${r.title||t('untitled')}`,
`**${t('status_model')}:** ${r.model||t('usage_default_model')}${provider?' ('+provider+')':''}`,
`**${t('status_profile')}:** ${profile}`,
`**${t('status_hermes_home')}:** ${r.hermes_home||t('status_unknown')}`,
`**${t('status_workspace')}:** ${r.workspace}`,
`**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`,
`**${t('status_started')}:** ${started}`,
`**${t('status_tokens')}:** ${tokens}${cost}`,
`**${t('status_messages')}:** ${r.message_count}`,
`**${t('status_agent_running')}:** ${r.agent_running?t('status_yes'):t('status_no')}`,
];
S.messages.push({role:'assistant',content:lines.join('\n')});
renderMessages();
}catch(e){showToast(t('status_load_failed')+e.message);}
}
function cmdReasoning(args){
const arg=(args||'').trim().toLowerCase();
const BRAIN='\uD83E\uDDE0';
// Matches hermes_constants.VALID_REASONING_EFFORTS + 'none' (CLI parity).
const EFFORTS=['none','minimal','low','medium','high','xhigh'];
// Shared status renderer used by the no-args branch and as a fallback.
function _fmtStatus(st){
const vis=(st && st.show_reasoning===false)?'off':'on';
const eff=(st && st.reasoning_effort)||'default';
return BRAIN+' Reasoning effort: '+eff+' \u00B7 display: '+vis
+' | /reasoning show|hide|none|minimal|low|medium|high|xhigh';
}
if(!arg){
// Status — read from the same config.yaml keys the CLI uses.
api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));})
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
return true;
}
if(arg==='show'||arg==='on'||arg==='hide'||arg==='off'){
const on=(arg==='show'||arg==='on');
// Update the UI render gate immediately for responsiveness.
window._showThinking=on;
if(typeof renderMessages==='function') renderMessages();
// Persist via /api/reasoning → config.yaml display.show_reasoning
// (CLI reads the same key). Also mirror into WebUI settings.json
// show_thinking so boot.js picks it up on reload without hitting
// /api/reasoning on every page load.
api('/api/reasoning',{method:'POST',body:JSON.stringify({display:arg})}).catch(function(){});
api('/api/settings',{method:'POST',body:JSON.stringify({show_thinking:on})}).catch(function(){});
showToast(BRAIN+' Thinking blocks: '+(on?'on':'off')+' (saved)');
return true;
}
if(EFFORTS.includes(arg)){
// Persist via /api/reasoning → config.yaml agent.reasoning_effort.
// Takes effect on the NEXT session/turn (agent re-reads config at
// construction time), matching CLI semantics where `/reasoning high`
// also forces an agent re-init.
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:arg})})
.then(function(st){
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff);
})
.catch(function(e){
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
});
return true;
}
showToast('Unknown argument: '+arg+' \u2014 use show|hide|'+EFFORTS.join('|'));
return true;
}
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'));
}
// ── YOLO mode toggle ──
// Session-scoped: skips all approval prompts for the current session.
// Toggles on/off; state is not persisted across page reloads.
async function cmdYolo(){
const sid=S.session&&S.session.session_id;
if(!sid){showToast(t('yolo_no_session'));return;}
try{
// Check current state first to toggle
const status=await api('/api/session/yolo?session_id='+encodeURIComponent(sid));
const enable=!status.yolo_enabled;
await api('/api/session/yolo',{
method:'POST',
body:JSON.stringify({session_id:sid,enabled:enable}),
});
_yoloEnabled=enable;
_updateYoloPill();
showToast(enable?t('yolo_enabled'):t('yolo_disabled'));
if(enable){
// Dismiss any visible approval card
hideApprovalCard(true);
}
}catch(e){showToast('YOLO: '+e.message);}
}
// ── Branch / fork command ──
// Forks the current conversation into a new session (#465).
// /branch → full history copy
// /branch My Name → full history copy with custom title
async function cmdBranch(args){
if(!S.session){showToast(t('no_active_session'));return;}
const customTitle=(args||'').trim()||null;
try{
const data=await api('/api/session/branch',{
method:'POST',
body:JSON.stringify({
session_id:S.session.session_id,
title:customTitle||undefined,
}),
});
if(data&&data.session_id){
await loadSession(data.session_id);
if(typeof renderSessionList==='function') await renderSessionList();
showToast(t('branch_forked'));
}
}catch(e){showToast(t('branch_failed')+e.message);}
}
// ── Fork from a specific message point ──
// Called from the "Fork from here" button on message hover actions.
async function forkFromMessage(msgIdx){
if(!S.session||S.busy)return;
try{
const data=await api('/api/session/branch',{
method:'POST',
body:JSON.stringify({
session_id:S.session.session_id,
keep_count:msgIdx,
}),
});
if(data&&data.session_id){
await loadSession(data.session_id);
if(typeof renderSessionList==='function') await renderSessionList();
showToast(t('branch_forked'));
}
}catch(e){showToast(t('branch_failed')+e.message);}
}
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;}
getSlashAutocompleteMatches(text).then(matches=>{
if(($('msg').value||'')!==text) return;
if(matches.length)showCmdDropdown(matches);else hideCmdDropdown();
});
}
function ensureSkillCommandsLoadedForAutocomplete(){
if(_skillCommandCacheReady||_skillCommandLoadPromise)return;
loadSkillCommands().then(()=>{refreshSlashCommandDropdown();});
}
// ── Autocomplete dropdown ───────────────────────────────────────────────────
let _cmdSelectedIdx=-1;
function showCmdDropdown(matches){
const dd=$('cmdDropdown');
if(!dd)return;
dd.innerHTML='';
_cmdSelectedIdx=matches.length?0:-1;
for(let i=0;i<matches.length;i++){
const c=matches[i];
const el=document.createElement('div');
el.className='cmd-item';
if(i===_cmdSelectedIdx) el.classList.add('selected');
el.dataset.idx=i;
const isSubArg=c.source==='subarg';
const usage=(!isSubArg&&c.arg)?` <span class="cmd-item-arg">${esc(c.arg)}</span>`:'';
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');
const nameHtml=isSubArg
? `<div class="cmd-item-name"><span class="cmd-item-parent">/${esc(c.parent)}</span> <span class="cmd-item-subarg">${esc(c.value)}</span></div>`
: `<div class="cmd-item-name">/${esc(c.name)}${usage}${badge}</div>`;
const descHtml=`<div class="cmd-item-desc">${esc(c.desc)}</div>`;
el.innerHTML=`${nameHtml}${descHtml}`;
el.onmousedown=(e)=>{
e.preventDefault();
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);
}
dd.classList.add('open');
}
function hideCmdDropdown(){
const dd=$('cmdDropdown');
if(dd)dd.classList.remove('open');
_cmdSelectedIdx=-1;
}
function navigateCmdDropdown(dir){
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');
// Scroll the newly highlighted item into view so it stays visible when the
// dropdown overflows and the user navigates with keyboard (#838).
items[_cmdSelectedIdx].scrollIntoView({block:'nearest'});
}
function selectCmdDropdownItem(){
const dd=$('cmdDropdown');
if(!dd)return;
const items=dd.querySelectorAll('.cmd-item');
if(_cmdSelectedIdx>=0&&_cmdSelectedIdx<items.length){
items[_cmdSelectedIdx].onmousedown({preventDefault:()=>{}});
} else if(items.length===1){
items[0].onmousedown({preventDefault:()=>{}});
}
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;