diff --git a/CHANGELOG.md b/CHANGELOG.md index bd23038..e2ddaac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Hermes Web UI -- Changelog -## [v0.50.85] — 2026-04-18 +## [v0.50.86] — 2026-04-18 + +### Added +- **Searchable model picker** — the model dropdown now has a live search input at the top. Type any part of a model name or ID to filter the list instantly; provider group headers (Anthropic, OpenAI, OpenRouter, etc.) remain visible in filtered results. Includes a clear button, Escape-to-close support, and a "No models found" empty state. i18n strings added for English, Spanish, and zh-CN. (PR #659 by @mmartial) + + ### Fixed - **`_provider_oauth_authenticated()` now respects the `hermes_home` parameter** — the function had a CLI fast path (`hermes_cli.auth.get_auth_status()`) that ignored the caller-supplied `hermes_home` and read from the real system home. On machines where `openai-codex` (or another OAuth provider) was genuinely authenticated, this caused three test assertions to return `True` instead of `False`, regardless of the isolated `tmp_path` the test passed in. Removed the CLI fast path; the function now reads exclusively from `hermes_home/auth.json`, which is both the correct scoped behavior and what the docstring described. No functional change for production (the auth.json path was already the complete fallback). (Fixes pre-existing test_sprint34 failures) diff --git a/static/i18n.js b/static/i18n.js index 1494612..f3d8ff9 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -58,8 +58,9 @@ const LOCALES = { provider_mismatch_label: 'Provider mismatch', model_custom_label: 'Custom model ID', model_custom_placeholder: 'e.g. openai/gpt-5.4', + model_search_placeholder: 'Search models…', + model_search_no_results: 'No models found', // commands.js - cmd_help: 'List available commands', cmd_clear: 'Clear conversation messages', cmd_compress: 'Manually compress conversation context (usage: /compress [focus topic])', cmd_compact_alias: 'Legacy alias for /compress', @@ -481,6 +482,8 @@ const LOCALES = { provider_mismatch_label: 'Proveedor incompatible', model_custom_label: 'ID de modelo personalizado', model_custom_placeholder: 'p. ej. openai/gpt-5.4', + model_search_placeholder: 'Buscar modelos…', + model_search_no_results: 'No se encontraron modelos', // commands.js cmd_help: 'Listar los comandos disponibles', cmd_clear: 'Borrar los mensajes de la conversación', @@ -1115,6 +1118,8 @@ const LOCALES = { provider_mismatch_label: '\u63d0\u4f9b\u5546\u4e0d\u5339\u914d', model_custom_label: '\u81ea\u5b9a\u4e49\u6a21\u578b ID', model_custom_placeholder: '\u4f8b\u5982 openai/gpt-5.4', + model_search_placeholder: '\u641c\u7d22\u6a21\u578b\u2026', + model_search_no_results: '\u672a\u627e\u5230\u6a21\u578b', // commands.js cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4', cmd_clear: '\u6e05\u7a7a\u5f53\u524d\u5bf9\u8bdd\u6d88\u606f', diff --git a/static/index.html b/static/index.html index db9952e..b3d4a06 100644 --- a/static/index.html +++ b/static/index.html @@ -592,7 +592,7 @@
System
Instance version and access controls.
- v0.50.85 + v0.50.86
diff --git a/static/style.css b/static/style.css index 983fdb8..ee1af51 100644 --- a/static/style.css +++ b/static/style.css @@ -775,6 +775,11 @@ .model-custom-sep{padding-top:4px;border-top:1px solid var(--border);margin-top:4px;} .model-custom-row{display:flex;align-items:center;gap:6px;padding:6px 10px 8px;} .model-custom-input{flex:1;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;font-family:inherit;min-width:0;} +.model-search-row{display:flex;align-items:center;gap:6px;padding:8px 10px 10px;} +.model-search-input{flex:1;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:6px 8px;font-size:12px;outline:none;font-family:inherit;min-width:0;} +.model-search-input:focus{border-color:var(--accent);} +.model-search-clear{flex-shrink:0;width:22px;height:22px;border:1px solid var(--border2);border-radius:50%;background:transparent;color:var(--muted);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:color .12s,border-color .12s;font-size:10px;} +.model-search-clear:hover{color:var(--text);border-color:var(--border);} .model-custom-input:focus{border-color:var(--accent);} .model-custom-btn{flex-shrink:0;width:24px;height:24px;border:1px solid var(--border2);border-radius:6px;background:transparent;color:var(--muted);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:color .12s,border-color .12s;} .model-custom-btn:hover{color:var(--accent-text);border-color:var(--accent-bg);} diff --git a/static/ui.js b/static/ui.js index 39204ca..f913eee 100644 --- a/static/ui.js +++ b/static/ui.js @@ -222,45 +222,99 @@ function renderModelDropdown(){ const dd=$('composerModelDropdown'); const sel=$('modelSelect'); if(!dd||!sel) return; - dd.innerHTML=''; + // Store model data for filtering + const _modelData=[]; for(const child of Array.from(sel.children)){ if(child.tagName==='OPTGROUP'){ - const heading=document.createElement('div'); - heading.className='model-group'; - heading.textContent=child.label||'Models'; - dd.appendChild(heading); for(const opt of Array.from(child.children)){ - const row=document.createElement('div'); - row.className='model-opt'+(opt.value===sel.value?' active':''); - row.innerHTML=`${esc(opt.textContent||getModelLabel(opt.value))}${esc(opt.value)}`; - row.onclick=()=>selectModelFromDropdown(opt.value); - dd.appendChild(row); + _modelData.push({value:opt.value,name:esc(opt.textContent||getModelLabel(opt.value)),id:esc(opt.value),group:child.label||''}); } - continue; } if(child.tagName==='OPTION'){ - const row=document.createElement('div'); - row.className='model-opt'+(child.value===sel.value?' active':''); - row.innerHTML=`${esc(child.textContent||getModelLabel(child.value))}${esc(child.value)}`; - row.onclick=()=>selectModelFromDropdown(child.value); - dd.appendChild(row); + _modelData.push({value:child.value,name:esc(child.textContent||getModelLabel(child.value)),id:esc(child.value),group:''}); } } - // Custom model ID input — lets users type any model not in the curated list + // Create search input FIRST before filterModels definition + const _searchRow=document.createElement('div'); + _searchRow.className='model-search-row'; + _searchRow.innerHTML=``; + const _si=_searchRow.querySelector('.model-search-input'); + const _sc=_searchRow.querySelector('.model-search-clear'); + // Create custom model section elements const _custSep=document.createElement('div'); _custSep.className='model-group model-custom-sep'; _custSep.textContent=t('model_custom_label')||'Custom model ID'; - dd.appendChild(_custSep); const _custRow=document.createElement('div'); _custRow.className='model-custom-row'; _custRow.innerHTML=``; const _ci=_custRow.querySelector('.model-custom-input'); const _cb=_custRow.querySelector('.model-custom-btn'); + // Filter function (defined AFTER _searchRow and _cust* are created) + const _filterModels=(term)=>{ + term=term.trim().toLowerCase(); + const found=new Set(); + for(const m of _modelData){ + const name=m.name.toLowerCase(); + const id=m.id.toLowerCase(); + if(name.includes(term)||id.includes(term)){ + found.add(m.value); + } + } + // Clear and rebuild + dd.innerHTML=''; + // Add search and custom elements first (CRITICAL: must be before models) + dd.appendChild(_searchRow); + dd.appendChild(_custSep); + dd.appendChild(_custRow); + // Add models matching filter + let _lastGroup=null; + for(const m of _modelData){ + if(!term||found.has(m.value)){ + if(m.group&&m.group!==_lastGroup){ + const heading=document.createElement('div'); + heading.className='model-group'; + heading.textContent=m.group; + dd.appendChild(heading); + _lastGroup=m.group; + } + const row=document.createElement('div'); + row.className='model-opt'+(m.value===sel.value?' active':''); + row.innerHTML=`${m.name}${m.id}`; + row.onclick=()=>selectModelFromDropdown(m.value); + dd.appendChild(row); + } + } + // Show "No results" if filtered and nothing matched + if(term&&found.size===0){ + const noResult=document.createElement('div'); + noResult.className='model-search-no-results'; + noResult.textContent=t('model_search_no_results')||'No models found'; + noResult.style.padding='12px 14px'; + noResult.style.color='var(--muted)'; + noResult.style.textAlign='center'; + dd.appendChild(noResult); + } + // Restore focus to search input + _si.focus(); + }; + // Event handlers for search input + _si.addEventListener('input',()=>_filterModels(_si.value)); + _si.addEventListener('keydown',e=>{if(e.key==='Enter') {e.preventDefault();}if(e.key==='Escape') {closeModelDropdown();}}); + _si.addEventListener('click',e=>e.stopPropagation()); + // Event handlers for clear button + _sc.onclick=()=>{ _si.value=''; _filterModels(''); _si.focus(); }; + _sc.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ _si.value=''; _filterModels(''); _si.focus(); e.preventDefault(); }}); + // Event handlers for custom input const _applyCustom=()=>{const v=_ci.value.trim();if(!v)return;selectModelFromDropdown(v);_ci.value='';}; _cb.onclick=_applyCustom; _ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeModelDropdown();}}); _ci.addEventListener('click',e=>e.stopPropagation()); + // Add search and custom elements to dropdown (initial render) + dd.appendChild(_searchRow); + dd.appendChild(_custSep); dd.appendChild(_custRow); + // Apply initial filter (empty shows all) + _filterModels(''); } async function selectModelFromDropdown(value){