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