From e931081a446b7712c2b90cc0f496b4c3249f529e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 20 Aug 2026 02:54:12 -0700 Subject: [PATCH] feat(cli): type-to-fuzzy-filter the /model picker model list --- cli.py | 111 +++++++++++++++++++++-- tests/cli/test_model_picker_filter.py | 62 +++++++++++++ website/docs/reference/slash-commands.md | 2 +- 3 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 tests/cli/test_model_picker_filter.py diff --git a/cli.py b/cli.py index ee210f7905..9d3bd17e94 100644 --- a/cli.py +++ b/cli.py @@ -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." diff --git a/tests/cli/test_model_picker_filter.py b/tests/cli/test_model_picker_filter.py new file mode 100644 index 0000000000..5b2ff9d00d --- /dev/null +++ b/tests/cli/test_model_picker_filter.py @@ -0,0 +1,62 @@ +"""Tests for the /model picker fuzzy filter (C-01). + +The filter narrows a provider's concrete model list as the user types, but +selection must still resolve to exactly ONE real model — never an ambiguous +or fuzzy resolution (the "claude → claude-sonnet-3" footgun). These pin the +index-preserving contract of ``_filter_model_picker_entries``. +""" + +from cli import HermesCLI + + +MODELS = [ + "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-haiku-4.5", + "openai/gpt-5.5", + "x-ai/grok-4.6", + "deepseek/deepseek-v4-flash", +] + + +def test_empty_query_returns_all_with_original_indices(): + pairs = HermesCLI._filter_model_picker_entries(MODELS, "") + assert pairs == list(enumerate(MODELS)) + + +def test_filter_narrows_and_preserves_original_index(): + pairs = HermesCLI._filter_model_picker_entries(MODELS, "grok") + # Only the grok row matches, and it carries its ORIGINAL index (4) so the + # selection handler resolves the exact concrete model. + assert pairs == [(4, "x-ai/grok-4.6")] + idx, label = pairs[0] + assert MODELS[idx] == label # index maps back to the real entry + + +def test_subsequence_match_case_insensitive(): + # "cs46" is a subsequence of "anthropic/claude-sonnet-4.6" + pairs = HermesCLI._filter_model_picker_entries(MODELS, "CS46") + assert ("anthropic/claude-sonnet-4.6") in [e for _i, e in pairs] + + +def test_no_match_returns_empty(): + assert HermesCLI._filter_model_picker_entries(MODELS, "zzzznope") == [] + + +def test_filter_does_not_reorder_or_pick_a_default(): + # Typing "claude" narrows to the three claude rows in ORIGINAL order — it + # never silently resolves to one (the anti-ambiguity guarantee). The user + # still explicitly selects among the concrete matches. + pairs = HermesCLI._filter_model_picker_entries(MODELS, "claude") + labels = [e for _i, e in pairs] + assert labels == [ + "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-haiku-4.5", + ] + # indices are the originals, in order + assert [i for i, _e in pairs] == [0, 1, 2] + + +def test_whitespace_query_is_treated_as_empty(): + assert HermesCLI._filter_model_picker_entries(MODELS, " ") == list(enumerate(MODELS)) diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 419a275d4a..f40cb0417b 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -74,7 +74,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| | `/config` | Show current configuration | -| `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint), and user-defined aliases (`/model fav`, `/model grok` — see [Custom model aliases](#custom-model-aliases)). Flags: `--global` persists the change to config.yaml; `--session` forces session-only; `--once` applies to the next turn only; `--refresh` re-fetches the provider's model list; `--provider ` switches backend (session-only unless `--global`). A plain `/model ` is session-only unless `model.persist_switch_by_default: true` is set. **Note:** `/model` can only switch between already-configured providers. To add a new provider, exit the session and run `hermes model` from your terminal. **Cost note:** switching models mid-conversation resets the prompt cache — the cache key includes the model, so your next turn re-reads the entire conversation at full input price instead of the ~75%-discounted cached rate. Expected and unavoidable, but worth knowing on long sessions. | +| `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint), and user-defined aliases (`/model fav`, `/model grok` — see [Custom model aliases](#custom-model-aliases)). Flags: `--global` persists the change to config.yaml; `--session` forces session-only; `--once` applies to the next turn only; `--refresh` re-fetches the provider's model list; `--provider ` switches backend (session-only unless `--global`). A plain `/model ` is session-only unless `model.persist_switch_by_default: true` is set. **Interactive picker:** running `/model` with no arguments opens the provider→model picker; on the model list you can **type to fuzzy-filter** the models (e.g. type `grok` to narrow to matching models), Backspace to trim the filter, Esc to clear it (or close the picker). Selection always resolves to one concrete model — the filter only narrows the list, it never guesses. **Note:** `/model` can only switch between already-configured providers. To add a new provider, exit the session and run `hermes model` from your terminal. **Cost note:** switching models mid-conversation resets the prompt cache — the cache key includes the model, so your next turn re-reads the entire conversation at full input price instead of the ~75%-discounted cached rate. Expected and unavoidable, but worth knowing on long sessions. | | `/codex-runtime [auto\|codex_app_server\|on\|off]` | Toggle the optional [Codex app-server runtime](../user-guide/features/codex-app-server-runtime) for OpenAI/Codex models. `auto` (default) uses Hermes' standard chat completions; `codex_app_server` hands turns to a `codex app-server` subprocess for native shell, apply_patch, ChatGPT subscription auth, and migrated Codex plugins. Effective on next session. | | `/personality` | Set a predefined personality. `/personality none` (or `default` / `neutral`) clears the overlay and returns to base behavior. | | `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. |