Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6820,6 +6820,28 @@ def _compute_model_picker_viewport(
scroll_offset = max(0, min(scroll_offset, n - visible))
return scroll_offset, visible

@staticmethod
def _wrap_model_picker_choice(choice: str, prefix: str, width: int) -> list[str]:
"""Wrap a model-picker row without losing its selection/indent prefix.

``textwrap.wrap()`` drops leading whitespace by default. Passing the
model row as ``" " + model_id`` therefore strips the two-space indent
whenever a long slash-delimited model ID reaches the wrap boundary,
making the row render as ``│ model`` instead of ``│ model``. Wrap the
raw model ID first and add the prefix afterwards so both selected and
unselected rows keep stable alignment.
"""
import textwrap

text_width = max(8, width - len(prefix))
wrapped = textwrap.wrap(
choice,
width=text_width,
break_long_words=False,
break_on_hyphens=False,
) or [""]
return [(prefix if i == 0 else " ") + line for i, line in enumerate(wrapped)]

def _apply_model_switch_result(self, result, persist_global: bool) -> None:
if not result.success:
_cprint(f" ✗ {result.error_message}")
Expand Down Expand Up @@ -6921,7 +6943,14 @@ def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
# Only fall back to the live provider catalog when the curated
# list is empty (e.g. user-defined endpoints with no curated list).
model_list = provider_data.get("models", [])
if not model_list:
total_models = provider_data.get("total_models", len(model_list))
# build_models_payload(..., max_models=50) intentionally truncates
# per-provider model lists so the provider picker stays cheap. Once
# the user enters a provider, hydrate the full live list whenever the
# row advertises more models than it carried. Without this, LiteLLM
# can correctly show "134 models" at the provider level but only let
# the user select the first 50 in the second-stage picker.
if not model_list or total_models > len(model_list):
try:
from hermes_cli.models import provider_model_ids
live = provider_model_ids(provider_data["slug"])
Expand Down Expand Up @@ -13268,7 +13297,7 @@ def _get_model_picker_display():
choice = choices[idx]
style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice'
prefix = '❯ ' if idx == selected else ' '
for wrapped in _wrap_panel_text(prefix + choice, inner_text_width, subsequent_indent=' '):
for wrapped in HermesCLI._wrap_model_picker_choice(choice, prefix, inner_text_width):
_append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width)
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n'))
Expand Down
94 changes: 94 additions & 0 deletions tests/hermes_cli/test_cli_model_picker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Regression tests for the prompt_toolkit /model picker."""

from types import SimpleNamespace
from unittest.mock import patch

from cli import HermesCLI


class _DummyCLI(SimpleNamespace):
def _invalidate(self, min_interval=0.0):
self.invalidated = True

def _close_model_picker(self): # pragma: no cover - safety if a test misroutes
self.closed = True


def test_provider_selection_hydrates_truncated_model_list():
"""Entering a provider should fetch the full list when the row was truncated.

build_models_payload(..., max_models=50) deliberately sends only the first N
models to keep the provider picker cheap, while preserving the full count in
total_models. The second-stage picker must not treat that truncated slice as
the complete selectable catalog.
"""
cli = _DummyCLI(
_model_picker_state={
"stage": "provider",
"selected": 0,
"providers": [
{
"slug": "litellm",
"name": "LiteLLM Proxy",
"models": ["model-a", "model-b"],
"total_models": 4,
}
],
}
)

with patch(
"hermes_cli.models.provider_model_ids",
return_value=["model-a", "model-b", "model-c", "model-d"],
) as live_fetch:
HermesCLI._handle_model_picker_selection(cli)

live_fetch.assert_called_once_with("litellm")
state = cli._model_picker_state
assert state["stage"] == "model"
assert state["model_list"] == ["model-a", "model-b", "model-c", "model-d"]
assert state["provider_data"]["slug"] == "litellm"
assert state["selected"] == 0
assert cli.invalidated is True


def test_model_picker_choice_wrap_preserves_unselected_indent_for_long_model_id():
"""Long slash-delimited model IDs must not lose the two-space row indent."""
long_model = "siliconflow-cn/Pro/deepseek-ai/DeepSeek-V3.1-Terminus"

wrapped = HermesCLI._wrap_model_picker_choice(long_model, " ", width=40)

assert wrapped == [" " + long_model]


def test_model_picker_choice_wrap_preserves_selected_prefix():
"""Selected rows keep the arrow prefix while wrapping the raw model ID."""
wrapped = HermesCLI._wrap_model_picker_choice("model with spaces here", "❯ ", width=14)

assert wrapped[0].startswith("❯ ")
assert all(line.startswith(("❯ ", " ")) for line in wrapped)


def test_provider_selection_keeps_complete_curated_model_list_without_live_fetch():
"""Complete curated rows should not be overwritten by provider_model_ids()."""
cli = _DummyCLI(
_model_picker_state={
"stage": "provider",
"selected": 0,
"providers": [
{
"slug": "nous",
"name": "Nous",
"models": ["agentic-a", "agentic-b"],
"total_models": 2,
}
],
}
)

with patch("hermes_cli.models.provider_model_ids") as live_fetch:
HermesCLI._handle_model_picker_selection(cli)

live_fetch.assert_not_called()
assert cli._model_picker_state["stage"] == "model"
assert cli._model_picker_state["model_list"] == ["agentic-a", "agentic-b"]