feat(ui): searchable model picker with provider group headers — v0.50.86 (PR #659 by @mmartial)

- Live search input in model dropdown (filter by name or ID)
- Provider group headers preserved in filtered view
- Clear button, Escape-to-close, No models found empty state
- i18n EN/ES/zh-CN strings
- CSS uses var(--accent) consistent with current theme system
- zh-CN double-escape fix included
- Provider headers regression fix included
- 1423 tests pass

Co-authored-by: mmartial <mmartial@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-18 09:27:36 -07:00
committed by GitHub
parent 5c2840e2da
commit 5266ee26bd
5 changed files with 90 additions and 21 deletions

View File

@@ -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)

View File

@@ -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',

View File

@@ -592,7 +592,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.85</span>
<span class="settings-version-badge">v0.50.86</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -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);}

View File

@@ -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=`<span class="model-opt-name">${esc(opt.textContent||getModelLabel(opt.value))}</span><span class="model-opt-id">${esc(opt.value)}</span>`;
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=`<span class="model-opt-name">${esc(child.textContent||getModelLabel(child.value))}</span><span class="model-opt-id">${esc(child.value)}</span>`;
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=`<input class="model-search-input" type="text" placeholder="${esc(t('model_search_placeholder')||'Search models…')}" spellcheck="false" autocomplete="off"><button class="model-search-clear" title="Clear search">${li('x',10)}</button>`;
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=`<input class="model-custom-input" type="text" placeholder="${esc(t('model_custom_placeholder')||'e.g. openai/gpt-5.4')}" spellcheck="false" autocomplete="off"><button class="model-custom-btn" title="Use this model">${li('plus',12)}</button>`;
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=`<span class="model-opt-name">${m.name}</span><span class="model-opt-id">${m.id}</span>`;
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){