feat(cli): type-to-fuzzy-filter the /model picker model list

This commit is contained in:
Teknium
2026-08-20 02:54:12 -07:00
parent 8265b873e0
commit e931081a44
3 changed files with 165 additions and 10 deletions
+102 -9
View File
@@ -10531,6 +10531,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"current_provider": current_provider,
"user_provs": user_provs,
"custom_provs": custom_provs,
"filter": "",
}
self._invalidate(min_interval=0.0)
@@ -10645,6 +10646,29 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception as exc:
logger.warning("CLI one-turn model restore failed: %s", exc)
@staticmethod
def _filter_model_picker_entries(entries: list, query: str) -> list:
"""Return (original_index, label) pairs for entries matching ``query``.
Subsequence ("fuzzy") match, case-insensitive: the query characters
must appear in order in the label. An empty query matches everything.
Crucially the returned pairs carry the ORIGINAL index into ``entries``,
so a selection in the filtered view still resolves to exactly one
concrete model — filtering only narrows the list, it never introduces
an ambiguous or fuzzy *resolution* (the anti-"claude→old-model" rule).
"""
pairs = list(enumerate(entries))
q = (query or "").strip().lower()
if not q:
return pairs
def _subseq(needle: str, hay: str) -> bool:
it = iter(hay)
return all(ch in it for ch in needle)
out = [(i, e) for (i, e) in pairs if _subseq(q, str(e).lower())]
return out
@staticmethod
def _compute_model_picker_viewport(
selected: int,
@@ -10874,24 +10898,36 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
state["provider_data"] = provider_data
state["model_list"] = model_list
state["selected"] = 0
state["filter"] = ""
state["_filtered_pairs"] = None
self._invalidate(min_interval=0.0)
return
if stage == "model":
provider_data = state.get("provider_data") or {}
model_list = state.get("model_list") or []
back_idx = len(model_list)
cancel_idx = len(model_list) + 1
# Map the selected row through the active fuzzy filter so the
# index lines up with what the picker is currently showing. The
# filtered pair carries the ORIGINAL index into model_list, so the
# resolved model is always one concrete, unambiguous entry.
filtered_pairs = state.get("_filtered_pairs")
if filtered_pairs is None:
filtered_pairs = list(enumerate(model_list))
visible_labels = [e for (_i, e) in filtered_pairs]
back_idx = len(visible_labels)
cancel_idx = len(visible_labels) + 1
if selected == back_idx:
state["stage"] = "provider"
state["filter"] = ""
state["_filtered_pairs"] = None
state["selected"] = next((i for i, p in enumerate(state.get("providers") or []) if p.get("slug") == provider_data.get("slug")), 0)
self._invalidate(min_interval=0.0)
return
if selected >= cancel_idx:
self._close_model_picker()
return
if selected < len(model_list):
if 0 <= selected < len(visible_labels):
from hermes_cli.model_switch import switch_model
chosen_model = model_list[selected]
chosen_model = visible_labels[selected]
result = switch_model(
raw_input=chosen_model,
current_provider=self.provider or "",
@@ -17995,13 +18031,57 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
if state.get("stage") == "provider":
max_idx = len(state.get("providers") or [])
else:
max_idx = len(state.get("model_list") or []) + 1
# +1 for "← Back" and Cancel over the filtered visible rows.
_fp = state.get("_filtered_pairs")
_visible = len(_fp) if _fp is not None else len(state.get("model_list") or [])
max_idx = _visible + 1
state["selected"] = min(max_idx, state.get("selected", 0) + 1)
event.app.invalidate()
def _model_picker_typing_active() -> bool:
# Type-to-filter is only live on the model stage (concrete list).
st = self._model_picker_state
return bool(st) and st.get("stage") == "model"
def _make_model_filter_char_handler(ch: str):
def handler(event):
st = self._model_picker_state
if not st or st.get("stage") != "model":
return
st["filter"] = (st.get("filter", "") or "") + ch
st["selected"] = 0
st["_scroll_offset"] = 0
event.app.invalidate()
return handler
# Printable ASCII (space through ~) narrows the model list as you type.
import string as _string
for _ch in _string.digits + _string.ascii_letters + "-_.:/ ":
kb.add(_ch, filter=Condition(_model_picker_typing_active))(
_make_model_filter_char_handler(_ch)
)
@kb.add('backspace', filter=Condition(_model_picker_typing_active))
def model_picker_filter_backspace(event):
st = self._model_picker_state
if not st:
return
cur = st.get("filter", "") or ""
st["filter"] = cur[:-1]
st["selected"] = 0
st["_scroll_offset"] = 0
event.app.invalidate()
@kb.add('escape', filter=Condition(lambda: bool(self._model_picker_state)), eager=True)
def model_picker_escape(event):
"""ESC closes the /model picker."""
"""ESC clears an active filter first, else closes the picker."""
st = self._model_picker_state
if st and st.get("stage") == "model" and (st.get("filter") or ""):
st["filter"] = ""
st["selected"] = 0
st["_scroll_offset"] = 0
event.app.invalidate()
return
self._close_model_picker()
event.app.current_buffer.reset()
event.app.invalidate()
@@ -19298,9 +19378,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
provider_data = state.get("provider_data") or {}
model_list = state.get("model_list") or []
title = f"⚙ Model Picker — {provider_data.get('name', provider_data.get('slug', 'Provider'))}"
choices = list(model_list) + ["← Back", "Cancel"]
if model_list:
hint = f"Select a model ({len(model_list)} available)"
# Fuzzy filter: narrow the concrete model list by the typed
# query. Selection still resolves to a real entry (see the
# filtered_pairs index mapping in the selection handler), so
# this never introduces an ambiguous model resolution.
_query = state.get("filter", "") or ""
filtered_pairs = cli_ref._filter_model_picker_entries(model_list, _query)
state["_filtered_pairs"] = filtered_pairs
model_labels = [e for (_i, e) in filtered_pairs]
choices = list(model_labels) + ["← Back", "Cancel"]
if _query:
hint = (
f"Filter: {_query}▏ ({len(model_labels)}/{len(model_list)} match "
"— type to narrow, Backspace to clear)"
)
elif model_list:
hint = f"Select a model ({len(model_list)} available) — type to filter"
else:
hint = "No models listed for this provider. Use Back or Cancel."