feat(cli): add native /model picker modal for provider → model selection
When /model is called with no arguments in the interactive CLI, open a two-step prompt_toolkit modal instead of the previous text-only listing: 1. Provider selection — curses_single_select with all authenticated providers 2. Model selection — live API fetch with curated fallback Also fixes: - OpenAI Codex model normalization (openai/gpt-5.4 → gpt-5.4) - Dedicated Codex validation path using provider_model_ids() Preserves curses_radiolist (used by setup, tools, plugins) alongside the new curses_single_select. Retains tool elapsed timer in spinner. Cherry-picked from PR #7438 by MestreY0d4-Uninter.
This commit is contained in:
committed by
Teknium
parent
39cd57083a
commit
06f862fa1b
475
cli.py
475
cli.py
@@ -1817,6 +1817,7 @@ class HermesCLI:
|
|||||||
self._approval_state = None
|
self._approval_state = None
|
||||||
self._approval_deadline = 0
|
self._approval_deadline = 0
|
||||||
self._approval_lock = threading.Lock()
|
self._approval_lock = threading.Lock()
|
||||||
|
self._model_picker_state = None
|
||||||
self._secret_state = None
|
self._secret_state = None
|
||||||
self._secret_deadline = 0
|
self._secret_deadline = 0
|
||||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||||
@@ -2059,7 +2060,7 @@ class HermesCLI:
|
|||||||
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
||||||
|
|
||||||
def _get_status_bar_fragments(self):
|
def _get_status_bar_fragments(self):
|
||||||
if not self._status_bar_visible:
|
if not self._status_bar_visible or getattr(self, '_model_picker_state', None):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
snapshot = self._get_status_bar_snapshot()
|
snapshot = self._get_status_bar_snapshot()
|
||||||
@@ -4291,6 +4292,265 @@ class HermesCLI:
|
|||||||
remaining = len(self.conversation_history)
|
remaining = len(self.conversation_history)
|
||||||
print(f" {remaining} message(s) remaining in history.")
|
print(f" {remaining} message(s) remaining in history.")
|
||||||
|
|
||||||
|
def _run_curses_picker(self, title: str, items: list[str], default_index: int = 0) -> int | None:
|
||||||
|
"""Run curses_single_select via run_in_terminal so prompt_toolkit handles terminal ownership cleanly."""
|
||||||
|
import threading
|
||||||
|
from hermes_cli.curses_ui import curses_single_select
|
||||||
|
|
||||||
|
result = [None]
|
||||||
|
|
||||||
|
def _pick():
|
||||||
|
result[0] = curses_single_select(title, items, default_index=default_index)
|
||||||
|
|
||||||
|
# run_in_terminal requires an asyncio event loop — only exists in the
|
||||||
|
# main prompt_toolkit thread. If we're in a background thread (e.g.
|
||||||
|
# process_loop), fall back to direct curses call.
|
||||||
|
in_main_thread = threading.current_thread() is threading.main_thread()
|
||||||
|
|
||||||
|
if self._app and in_main_thread:
|
||||||
|
from prompt_toolkit.application import run_in_terminal
|
||||||
|
was_visible = self._status_bar_visible
|
||||||
|
self._status_bar_visible = False
|
||||||
|
self._app.invalidate()
|
||||||
|
try:
|
||||||
|
run_in_terminal(_pick)
|
||||||
|
finally:
|
||||||
|
self._status_bar_visible = was_visible
|
||||||
|
self._app.invalidate()
|
||||||
|
else:
|
||||||
|
_pick()
|
||||||
|
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
def _prompt_text_input(self, prompt_text: str) -> str | None:
|
||||||
|
"""Prompt for free-text input safely inside or outside prompt_toolkit."""
|
||||||
|
result = [None]
|
||||||
|
|
||||||
|
def _ask():
|
||||||
|
try:
|
||||||
|
result[0] = input(prompt_text).strip() or None
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self._app:
|
||||||
|
from prompt_toolkit.application import run_in_terminal
|
||||||
|
was_visible = self._status_bar_visible
|
||||||
|
self._status_bar_visible = False
|
||||||
|
self._app.invalidate()
|
||||||
|
try:
|
||||||
|
run_in_terminal(_ask)
|
||||||
|
finally:
|
||||||
|
self._status_bar_visible = was_visible
|
||||||
|
self._app.invalidate()
|
||||||
|
else:
|
||||||
|
_ask()
|
||||||
|
return result[0]
|
||||||
|
|
||||||
|
def _interactive_provider_selection(
|
||||||
|
self, providers: list, current_model: str, current_provider: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Show provider picker, return slug or None on cancel."""
|
||||||
|
choices = []
|
||||||
|
for p in providers:
|
||||||
|
count = p.get("total_models", len(p.get("models", [])))
|
||||||
|
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
|
||||||
|
if p.get("is_current"):
|
||||||
|
label += " ← current"
|
||||||
|
choices.append(label)
|
||||||
|
|
||||||
|
default_idx = next(
|
||||||
|
(i for i, p in enumerate(providers) if p.get("is_current")), 0
|
||||||
|
)
|
||||||
|
|
||||||
|
idx = self._run_curses_picker(
|
||||||
|
f"Select a provider (current: {current_model} on {current_provider}):",
|
||||||
|
choices,
|
||||||
|
default_index=default_idx,
|
||||||
|
)
|
||||||
|
if idx is None:
|
||||||
|
return None
|
||||||
|
return providers[idx]["slug"]
|
||||||
|
|
||||||
|
def _interactive_model_selection(
|
||||||
|
self, model_list: list, provider_data: dict
|
||||||
|
) -> str | None:
|
||||||
|
"""Show model picker for a given provider, return model_id or None on cancel."""
|
||||||
|
pname = provider_data.get("name", provider_data.get("slug", ""))
|
||||||
|
total = provider_data.get("total_models", len(model_list))
|
||||||
|
|
||||||
|
if not model_list:
|
||||||
|
_cprint(f"\n No models listed for {pname}.")
|
||||||
|
return self._prompt_text_input(" Enter model name manually (or Enter to cancel): ")
|
||||||
|
|
||||||
|
choices = list(model_list) + ["Enter custom model name"]
|
||||||
|
idx = self._run_curses_picker(
|
||||||
|
f"Select model from {pname} ({len(model_list)} of {total}):",
|
||||||
|
choices,
|
||||||
|
)
|
||||||
|
if idx is None:
|
||||||
|
return None
|
||||||
|
if idx < len(model_list):
|
||||||
|
return model_list[idx]
|
||||||
|
return self._prompt_text_input(" Enter model name: ")
|
||||||
|
|
||||||
|
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
|
||||||
|
"""Open prompt_toolkit-native /model picker modal."""
|
||||||
|
self._capture_modal_input_snapshot()
|
||||||
|
default_idx = next((i for i, p in enumerate(providers) if p.get("is_current")), 0)
|
||||||
|
self._model_picker_state = {
|
||||||
|
"stage": "provider",
|
||||||
|
"providers": providers,
|
||||||
|
"selected": default_idx,
|
||||||
|
"current_model": current_model,
|
||||||
|
"current_provider": current_provider,
|
||||||
|
"user_provs": user_provs,
|
||||||
|
"custom_provs": custom_provs,
|
||||||
|
}
|
||||||
|
self._invalidate(min_interval=0.0)
|
||||||
|
|
||||||
|
def _close_model_picker(self) -> None:
|
||||||
|
self._model_picker_state = None
|
||||||
|
self._restore_modal_input_snapshot()
|
||||||
|
self._invalidate(min_interval=0.0)
|
||||||
|
|
||||||
|
def _apply_model_switch_result(self, result, persist_global: bool) -> None:
|
||||||
|
if not result.success:
|
||||||
|
_cprint(f" ✗ {result.error_message}")
|
||||||
|
return
|
||||||
|
|
||||||
|
old_model = self.model
|
||||||
|
self.model = result.new_model
|
||||||
|
self.provider = result.target_provider
|
||||||
|
self.requested_provider = result.target_provider
|
||||||
|
if result.api_key:
|
||||||
|
self.api_key = result.api_key
|
||||||
|
self._explicit_api_key = result.api_key
|
||||||
|
if result.base_url:
|
||||||
|
self.base_url = result.base_url
|
||||||
|
self._explicit_base_url = result.base_url
|
||||||
|
if result.api_mode:
|
||||||
|
self.api_mode = result.api_mode
|
||||||
|
|
||||||
|
if self.agent is not None:
|
||||||
|
try:
|
||||||
|
self.agent.switch_model(
|
||||||
|
new_model=result.new_model,
|
||||||
|
new_provider=result.target_provider,
|
||||||
|
api_key=result.api_key,
|
||||||
|
base_url=result.base_url,
|
||||||
|
api_mode=result.api_mode,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_cprint(f" ⚠ Agent swap failed ({exc}); change applied to next session.")
|
||||||
|
|
||||||
|
self._pending_model_switch_note = (
|
||||||
|
f"[Note: model was just switched from {old_model} to {result.new_model} "
|
||||||
|
f"via {result.provider_label or result.target_provider}. "
|
||||||
|
f"Adjust your self-identification accordingly.]"
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_label = result.provider_label or result.target_provider
|
||||||
|
_cprint(f" ✓ Model switched: {result.new_model}")
|
||||||
|
_cprint(f" Provider: {provider_label}")
|
||||||
|
|
||||||
|
mi = result.model_info
|
||||||
|
if mi:
|
||||||
|
if mi.context_window:
|
||||||
|
_cprint(f" Context: {mi.context_window:,} tokens")
|
||||||
|
if mi.max_output:
|
||||||
|
_cprint(f" Max output: {mi.max_output:,} tokens")
|
||||||
|
if mi.has_cost_data():
|
||||||
|
_cprint(f" Cost: {mi.format_cost()}")
|
||||||
|
_cprint(f" Capabilities: {mi.format_capabilities()}")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from agent.model_metadata import get_model_context_length
|
||||||
|
ctx = get_model_context_length(
|
||||||
|
result.new_model,
|
||||||
|
base_url=result.base_url or self.base_url,
|
||||||
|
api_key=result.api_key or self.api_key,
|
||||||
|
provider=result.target_provider,
|
||||||
|
)
|
||||||
|
_cprint(f" Context: {ctx:,} tokens")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cache_enabled = (
|
||||||
|
("openrouter" in (result.base_url or "").lower() and "claude" in result.new_model.lower())
|
||||||
|
or result.api_mode == "anthropic_messages"
|
||||||
|
)
|
||||||
|
if cache_enabled:
|
||||||
|
_cprint(" Prompt caching: enabled")
|
||||||
|
if result.warning_message:
|
||||||
|
_cprint(f" ⚠ {result.warning_message}")
|
||||||
|
if persist_global:
|
||||||
|
save_config_value("model.default", result.new_model)
|
||||||
|
if result.provider_changed:
|
||||||
|
save_config_value("model.provider", result.target_provider)
|
||||||
|
_cprint(" Saved to config.yaml (--global)")
|
||||||
|
else:
|
||||||
|
_cprint(" (session only — add --global to persist)")
|
||||||
|
|
||||||
|
def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
|
||||||
|
state = self._model_picker_state
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
selected = state.get("selected", 0)
|
||||||
|
stage = state.get("stage")
|
||||||
|
if stage == "provider":
|
||||||
|
providers = state.get("providers") or []
|
||||||
|
if selected >= len(providers):
|
||||||
|
self._close_model_picker()
|
||||||
|
return
|
||||||
|
provider_data = providers[selected]
|
||||||
|
model_list = []
|
||||||
|
try:
|
||||||
|
from hermes_cli.models import provider_model_ids
|
||||||
|
live = provider_model_ids(provider_data["slug"])
|
||||||
|
if live:
|
||||||
|
model_list = live
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not model_list:
|
||||||
|
model_list = provider_data.get("models", [])
|
||||||
|
state["stage"] = "model"
|
||||||
|
state["provider_data"] = provider_data
|
||||||
|
state["model_list"] = model_list
|
||||||
|
state["selected"] = 0
|
||||||
|
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
|
||||||
|
if selected == back_idx:
|
||||||
|
state["stage"] = "provider"
|
||||||
|
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):
|
||||||
|
from hermes_cli.model_switch import switch_model
|
||||||
|
chosen_model = model_list[selected]
|
||||||
|
result = switch_model(
|
||||||
|
raw_input=chosen_model,
|
||||||
|
current_provider=self.provider or "",
|
||||||
|
current_model=self.model or "",
|
||||||
|
current_base_url=self.base_url or "",
|
||||||
|
current_api_key=self.api_key or "",
|
||||||
|
is_global=persist_global,
|
||||||
|
explicit_provider=provider_data.get("slug"),
|
||||||
|
user_providers=state.get("user_provs"),
|
||||||
|
custom_providers=state.get("custom_provs"),
|
||||||
|
)
|
||||||
|
self._close_model_picker()
|
||||||
|
self._apply_model_switch_result(result, persist_global)
|
||||||
|
return
|
||||||
|
self._close_model_picker()
|
||||||
|
|
||||||
def _handle_model_switch(self, cmd_original: str):
|
def _handle_model_switch(self, cmd_original: str):
|
||||||
"""Handle /model command — switch model for this session.
|
"""Handle /model command — switch model for this session.
|
||||||
|
|
||||||
@@ -4313,56 +4573,46 @@ class HermesCLI:
|
|||||||
|
|
||||||
user_provs = None
|
user_provs = None
|
||||||
custom_provs = None
|
custom_provs = None
|
||||||
try:
|
|
||||||
from hermes_cli.config import load_config
|
|
||||||
cfg = load_config()
|
|
||||||
user_provs = cfg.get("providers")
|
|
||||||
custom_provs = cfg.get("custom_providers")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# No args at all: show available providers + models
|
# No args at all: open prompt_toolkit-native picker modal
|
||||||
if not model_input and not explicit_provider:
|
if not model_input and not explicit_provider:
|
||||||
model_display = self.model or "unknown"
|
model_display = self.model or "unknown"
|
||||||
provider_display = get_label(self.provider) if self.provider else "unknown"
|
provider_display = get_label(self.provider) if self.provider else "unknown"
|
||||||
_cprint(f" Current: {model_display} on {provider_display}")
|
|
||||||
_cprint("")
|
|
||||||
|
|
||||||
# Show authenticated providers with top models
|
user_provs = None
|
||||||
|
custom_provs = None
|
||||||
|
try:
|
||||||
|
from hermes_cli.config import load_config
|
||||||
|
cfg = load_config()
|
||||||
|
user_provs = cfg.get("providers")
|
||||||
|
custom_provs = cfg.get("custom_providers")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
providers = list_authenticated_providers(
|
providers = list_authenticated_providers(
|
||||||
current_provider=self.provider or "",
|
current_provider=self.provider or "",
|
||||||
user_providers=user_provs,
|
user_providers=user_provs,
|
||||||
custom_providers=custom_provs,
|
custom_providers=custom_provs,
|
||||||
max_models=6,
|
max_models=50,
|
||||||
)
|
)
|
||||||
if providers:
|
|
||||||
for p in providers:
|
|
||||||
tag = " (current)" if p["is_current"] else ""
|
|
||||||
_cprint(f" {p['name']} [--provider {p['slug']}]{tag}:")
|
|
||||||
if p["models"]:
|
|
||||||
model_strs = ", ".join(p["models"])
|
|
||||||
extra = f" (+{p['total_models'] - len(p['models'])} more)" if p["total_models"] > len(p["models"]) else ""
|
|
||||||
_cprint(f" {model_strs}{extra}")
|
|
||||||
elif p.get("api_url"):
|
|
||||||
_cprint(f" {p['api_url']} (use /model <name> --provider {p['slug']})")
|
|
||||||
else:
|
|
||||||
_cprint(f" (no models listed)")
|
|
||||||
_cprint("")
|
|
||||||
else:
|
|
||||||
_cprint(" No authenticated providers found.")
|
|
||||||
_cprint("")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
providers = []
|
||||||
|
|
||||||
# Aliases
|
if not providers:
|
||||||
from hermes_cli.model_switch import MODEL_ALIASES
|
_cprint(" No authenticated providers found.")
|
||||||
alias_list = ", ".join(sorted(MODEL_ALIASES.keys()))
|
_cprint("")
|
||||||
_cprint(f" Aliases: {alias_list}")
|
_cprint(" /model <name> switch model")
|
||||||
_cprint("")
|
_cprint(" /model --provider <slug> switch provider")
|
||||||
_cprint(" /model <name> switch model")
|
return
|
||||||
_cprint(" /model <name> --provider <slug> switch provider")
|
|
||||||
_cprint(" /model <name> --global persist to config")
|
self._open_model_picker(
|
||||||
|
providers,
|
||||||
|
model_display,
|
||||||
|
provider_display,
|
||||||
|
user_provs=user_provs,
|
||||||
|
custom_provs=custom_provs,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Perform the switch
|
# Perform the switch
|
||||||
@@ -4470,6 +4720,18 @@ class HermesCLI:
|
|||||||
else:
|
else:
|
||||||
_cprint(" (session only — add --global to persist)")
|
_cprint(" (session only — add --global to persist)")
|
||||||
|
|
||||||
|
def _should_handle_model_command_inline(self, text: str, has_images: bool = False) -> bool:
|
||||||
|
"""Return True when /model should be handled immediately on the UI thread."""
|
||||||
|
if not text or has_images or not _looks_like_slash_command(text):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from hermes_cli.commands import resolve_command
|
||||||
|
base = text.split(None, 1)[0].lower().lstrip('/')
|
||||||
|
cmd = resolve_command(base)
|
||||||
|
return bool(cmd and cmd.name == "model")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _show_model_and_providers(self):
|
def _show_model_and_providers(self):
|
||||||
"""Show current model + provider and list all authenticated providers.
|
"""Show current model + provider and list all authenticated providers.
|
||||||
|
|
||||||
@@ -7692,7 +7954,8 @@ class HermesCLI:
|
|||||||
secret_widget,
|
secret_widget,
|
||||||
approval_widget,
|
approval_widget,
|
||||||
clarify_widget,
|
clarify_widget,
|
||||||
spinner_widget,
|
model_picker_widget=None,
|
||||||
|
spinner_widget=None,
|
||||||
spacer,
|
spacer,
|
||||||
status_bar,
|
status_bar,
|
||||||
input_rule_top,
|
input_rule_top,
|
||||||
@@ -7709,21 +7972,24 @@ class HermesCLI:
|
|||||||
ordering.
|
ordering.
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
Window(height=0),
|
item for item in [
|
||||||
sudo_widget,
|
Window(height=0),
|
||||||
secret_widget,
|
sudo_widget,
|
||||||
approval_widget,
|
secret_widget,
|
||||||
clarify_widget,
|
approval_widget,
|
||||||
spinner_widget,
|
clarify_widget,
|
||||||
spacer,
|
model_picker_widget,
|
||||||
*self._get_extra_tui_widgets(),
|
spinner_widget,
|
||||||
status_bar,
|
spacer,
|
||||||
input_rule_top,
|
*self._get_extra_tui_widgets(),
|
||||||
image_bar,
|
status_bar,
|
||||||
input_area,
|
input_rule_top,
|
||||||
input_rule_bot,
|
image_bar,
|
||||||
voice_status_bar,
|
input_area,
|
||||||
completions_menu,
|
input_rule_bot,
|
||||||
|
voice_status_bar,
|
||||||
|
completions_menu,
|
||||||
|
] if item is not None
|
||||||
]
|
]
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
@@ -7884,6 +8150,12 @@ class HermesCLI:
|
|||||||
event.app.invalidate()
|
event.app.invalidate()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# --- /model picker modal ---
|
||||||
|
if self._model_picker_state:
|
||||||
|
self._handle_model_picker_selection()
|
||||||
|
event.app.invalidate()
|
||||||
|
return
|
||||||
|
|
||||||
# --- Clarify freetext mode: user typed their own answer ---
|
# --- Clarify freetext mode: user typed their own answer ---
|
||||||
if self._clarify_freetext and self._clarify_state:
|
if self._clarify_freetext and self._clarify_state:
|
||||||
text = event.app.current_buffer.text.strip()
|
text = event.app.current_buffer.text.strip()
|
||||||
@@ -7914,6 +8186,16 @@ class HermesCLI:
|
|||||||
text = event.app.current_buffer.text.strip()
|
text = event.app.current_buffer.text.strip()
|
||||||
has_images = bool(self._attached_images)
|
has_images = bool(self._attached_images)
|
||||||
if text or has_images:
|
if text or has_images:
|
||||||
|
# Handle /model directly on the UI thread so interactive pickers
|
||||||
|
# can safely use prompt_toolkit terminal handoff helpers.
|
||||||
|
if self._should_handle_model_command_inline(text, has_images=has_images):
|
||||||
|
if not self.process_command(text):
|
||||||
|
self._should_exit = True
|
||||||
|
if event.app.is_running:
|
||||||
|
event.app.exit()
|
||||||
|
event.app.current_buffer.reset(append_to_history=True)
|
||||||
|
return
|
||||||
|
|
||||||
# Snapshot and clear attached images
|
# Snapshot and clear attached images
|
||||||
images = list(self._attached_images)
|
images = list(self._attached_images)
|
||||||
self._attached_images.clear()
|
self._attached_images.clear()
|
||||||
@@ -8017,12 +8299,31 @@ class HermesCLI:
|
|||||||
self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
|
self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
|
||||||
event.app.invalidate()
|
event.app.invalidate()
|
||||||
|
|
||||||
|
# --- /model picker: arrow-key navigation ---
|
||||||
|
@kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
|
||||||
|
def model_picker_up(event):
|
||||||
|
if self._model_picker_state:
|
||||||
|
self._model_picker_state["selected"] = max(0, self._model_picker_state.get("selected", 0) - 1)
|
||||||
|
event.app.invalidate()
|
||||||
|
|
||||||
|
@kb.add('down', filter=Condition(lambda: bool(self._model_picker_state)))
|
||||||
|
def model_picker_down(event):
|
||||||
|
state = self._model_picker_state
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
if state.get("stage") == "provider":
|
||||||
|
max_idx = len(state.get("providers") or [])
|
||||||
|
else:
|
||||||
|
max_idx = len(state.get("model_list") or []) + 1
|
||||||
|
state["selected"] = min(max_idx, state.get("selected", 0) + 1)
|
||||||
|
event.app.invalidate()
|
||||||
|
|
||||||
# --- History navigation: up/down browse history in normal input mode ---
|
# --- History navigation: up/down browse history in normal input mode ---
|
||||||
# The TextArea is multiline, so by default up/down only move the cursor.
|
# The TextArea is multiline, so by default up/down only move the cursor.
|
||||||
# Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
|
# Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
|
||||||
# history browsing when on the first/last line (or single-line input).
|
# history browsing when on the first/last line (or single-line input).
|
||||||
_normal_input = Condition(
|
_normal_input = Condition(
|
||||||
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
|
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
|
||||||
)
|
)
|
||||||
|
|
||||||
@kb.add('up', filter=_normal_input)
|
@kb.add('up', filter=_normal_input)
|
||||||
@@ -8088,6 +8389,13 @@ class HermesCLI:
|
|||||||
event.app.invalidate()
|
event.app.invalidate()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Cancel /model picker
|
||||||
|
if self._model_picker_state:
|
||||||
|
self._close_model_picker()
|
||||||
|
event.app.current_buffer.reset()
|
||||||
|
event.app.invalidate()
|
||||||
|
return
|
||||||
|
|
||||||
# Cancel clarify prompt
|
# Cancel clarify prompt
|
||||||
if self._clarify_state:
|
if self._clarify_state:
|
||||||
self._clarify_state["response_queue"].put(
|
self._clarify_state["response_queue"].put(
|
||||||
@@ -8140,7 +8448,7 @@ class HermesCLI:
|
|||||||
agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent")
|
agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent")
|
||||||
msg = f"\n{agent_name} has been suspended. Run `fg` to bring {agent_name} back."
|
msg = f"\n{agent_name} has been suspended. Run `fg` to bring {agent_name} back."
|
||||||
def _suspend():
|
def _suspend():
|
||||||
os.write(1, msg.encode("utf-8", errors="replace"))
|
os.write(1, msg.encode())
|
||||||
os.kill(0, _sig.SIGTSTP)
|
os.kill(0, _sig.SIGTSTP)
|
||||||
run_in_terminal(_suspend)
|
run_in_terminal(_suspend)
|
||||||
|
|
||||||
@@ -8705,6 +9013,60 @@ class HermesCLI:
|
|||||||
filter=Condition(lambda: cli_ref._approval_state is not None),
|
filter=Condition(lambda: cli_ref._approval_state is not None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- /model picker: display widget ---
|
||||||
|
def _get_model_picker_display():
|
||||||
|
state = cli_ref._model_picker_state
|
||||||
|
if not state:
|
||||||
|
return []
|
||||||
|
stage = state.get("stage", "provider")
|
||||||
|
if stage == "provider":
|
||||||
|
title = "⚙ Model Picker — Select Provider"
|
||||||
|
choices = []
|
||||||
|
for p in state.get("providers") or []:
|
||||||
|
count = p.get("total_models", len(p.get("models", [])))
|
||||||
|
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
|
||||||
|
if p.get("is_current"):
|
||||||
|
label += " ← current"
|
||||||
|
choices.append(label)
|
||||||
|
choices.append("Cancel")
|
||||||
|
hint = f"Current: {state.get('current_model', 'unknown')} on {state.get('current_provider', 'unknown')}"
|
||||||
|
else:
|
||||||
|
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)"
|
||||||
|
else:
|
||||||
|
hint = "No models listed for this provider. Use Back or Cancel."
|
||||||
|
|
||||||
|
box_width = _panel_box_width(title, [hint] + choices, min_width=46, max_width=84)
|
||||||
|
inner_text_width = max(8, box_width - 6)
|
||||||
|
lines = []
|
||||||
|
lines.append(('class:clarify-border', '╭─ '))
|
||||||
|
lines.append(('class:clarify-title', title))
|
||||||
|
lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n'))
|
||||||
|
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||||
|
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width)
|
||||||
|
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||||
|
selected = state.get("selected", 0)
|
||||||
|
for idx, choice in enumerate(choices):
|
||||||
|
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=' '):
|
||||||
|
_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'))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
model_picker_widget = ConditionalContainer(
|
||||||
|
Window(
|
||||||
|
FormattedTextControl(_get_model_picker_display),
|
||||||
|
wrap_lines=True,
|
||||||
|
),
|
||||||
|
filter=Condition(lambda: cli_ref._model_picker_state is not None),
|
||||||
|
)
|
||||||
|
|
||||||
# Horizontal rules above and below the input.
|
# Horizontal rules above and below the input.
|
||||||
# On narrow/mobile terminals we keep the top separator for structure but
|
# On narrow/mobile terminals we keep the top separator for structure but
|
||||||
# hide the bottom one to recover a full row for conversation content.
|
# hide the bottom one to recover a full row for conversation content.
|
||||||
@@ -8780,6 +9142,7 @@ class HermesCLI:
|
|||||||
secret_widget=secret_widget,
|
secret_widget=secret_widget,
|
||||||
approval_widget=approval_widget,
|
approval_widget=approval_widget,
|
||||||
clarify_widget=clarify_widget,
|
clarify_widget=clarify_widget,
|
||||||
|
model_picker_widget=model_picker_widget,
|
||||||
spinner_widget=spinner_widget,
|
spinner_widget=spinner_widget,
|
||||||
spacer=spacer,
|
spacer=spacer,
|
||||||
status_bar=status_bar,
|
status_bar=status_bar,
|
||||||
|
|||||||
@@ -287,6 +287,129 @@ def _radio_numbered_fallback(
|
|||||||
return cancel_returns
|
return cancel_returns
|
||||||
|
|
||||||
|
|
||||||
|
def curses_single_select(
|
||||||
|
title: str,
|
||||||
|
items: List[str],
|
||||||
|
default_index: int = 0,
|
||||||
|
*,
|
||||||
|
cancel_label: str = "Cancel",
|
||||||
|
) -> int | None:
|
||||||
|
"""Curses single-select menu. Returns selected index or None on cancel.
|
||||||
|
|
||||||
|
Works inside prompt_toolkit because curses.wrapper() restores the terminal
|
||||||
|
safely, unlike simple_term_menu which conflicts with /dev/tty.
|
||||||
|
"""
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import curses
|
||||||
|
result_holder: list = [None]
|
||||||
|
|
||||||
|
all_items = list(items) + [cancel_label]
|
||||||
|
cancel_idx = len(items)
|
||||||
|
|
||||||
|
def _draw(stdscr):
|
||||||
|
curses.curs_set(0)
|
||||||
|
if curses.has_colors():
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||||
|
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||||
|
cursor = min(default_index, len(all_items) - 1)
|
||||||
|
scroll_offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
stdscr.clear()
|
||||||
|
max_y, max_x = stdscr.getmaxyx()
|
||||||
|
|
||||||
|
try:
|
||||||
|
hattr = curses.A_BOLD
|
||||||
|
if curses.has_colors():
|
||||||
|
hattr |= curses.color_pair(2)
|
||||||
|
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||||
|
stdscr.addnstr(
|
||||||
|
1, 0,
|
||||||
|
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||||
|
max_x - 1, curses.A_DIM,
|
||||||
|
)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
visible_rows = max_y - 3
|
||||||
|
if cursor < scroll_offset:
|
||||||
|
scroll_offset = cursor
|
||||||
|
elif cursor >= scroll_offset + visible_rows:
|
||||||
|
scroll_offset = cursor - visible_rows + 1
|
||||||
|
|
||||||
|
for draw_i, i in enumerate(
|
||||||
|
range(scroll_offset, min(len(all_items), scroll_offset + visible_rows))
|
||||||
|
):
|
||||||
|
y = draw_i + 3
|
||||||
|
if y >= max_y - 1:
|
||||||
|
break
|
||||||
|
arrow = "→" if i == cursor else " "
|
||||||
|
line = f" {arrow} {all_items[i]}"
|
||||||
|
attr = curses.A_NORMAL
|
||||||
|
if i == cursor:
|
||||||
|
attr = curses.A_BOLD
|
||||||
|
if curses.has_colors():
|
||||||
|
attr |= curses.color_pair(1)
|
||||||
|
try:
|
||||||
|
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stdscr.refresh()
|
||||||
|
key = stdscr.getch()
|
||||||
|
|
||||||
|
if key in (curses.KEY_UP, ord("k")):
|
||||||
|
cursor = (cursor - 1) % len(all_items)
|
||||||
|
elif key in (curses.KEY_DOWN, ord("j")):
|
||||||
|
cursor = (cursor + 1) % len(all_items)
|
||||||
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
result_holder[0] = cursor
|
||||||
|
return
|
||||||
|
elif key in (27, ord("q")):
|
||||||
|
result_holder[0] = None
|
||||||
|
return
|
||||||
|
|
||||||
|
curses.wrapper(_draw)
|
||||||
|
flush_stdin()
|
||||||
|
if result_holder[0] is not None and result_holder[0] >= cancel_idx:
|
||||||
|
return None
|
||||||
|
return result_holder[0]
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
all_items = list(items) + [cancel_label]
|
||||||
|
cancel_idx = len(items)
|
||||||
|
return _numbered_single_fallback(title, all_items, cancel_idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _numbered_single_fallback(
|
||||||
|
title: str,
|
||||||
|
items: List[str],
|
||||||
|
cancel_idx: int,
|
||||||
|
) -> int | None:
|
||||||
|
"""Text-based numbered fallback for single-select."""
|
||||||
|
print(f"\n {title}\n")
|
||||||
|
for i, label in enumerate(items, 1):
|
||||||
|
print(f" {i}. {label}")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
val = input(f" Choice [1-{len(items)}]: ").strip()
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
idx = int(val) - 1
|
||||||
|
if 0 <= idx < len(items) and idx < cancel_idx:
|
||||||
|
return idx
|
||||||
|
if idx == cancel_idx:
|
||||||
|
return None
|
||||||
|
except (ValueError, KeyboardInterrupt, EOFError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _numbered_fallback(
|
def _numbered_fallback(
|
||||||
title: str,
|
title: str,
|
||||||
items: List[str],
|
items: List[str],
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ _DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({
|
|||||||
_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
_STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
||||||
"copilot",
|
"copilot",
|
||||||
"copilot-acp",
|
"copilot-acp",
|
||||||
|
"openai-codex",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Providers whose native naming is authoritative -- pass through unchanged.
|
# Providers whose native naming is authoritative -- pass through unchanged.
|
||||||
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
|
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
|
||||||
"gemini",
|
"gemini",
|
||||||
"huggingface",
|
"huggingface",
|
||||||
"openai-codex",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Direct providers that accept bare native names but should repair a matching
|
# Direct providers that accept bare native names but should repair a matching
|
||||||
@@ -360,7 +360,11 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str:
|
|||||||
|
|
||||||
# --- Copilot: strip matching provider prefix, keep dots ---
|
# --- Copilot: strip matching provider prefix, keep dots ---
|
||||||
if provider in _STRIP_VENDOR_ONLY_PROVIDERS:
|
if provider in _STRIP_VENDOR_ONLY_PROVIDERS:
|
||||||
return _strip_matching_provider_prefix(name, provider)
|
stripped = _strip_matching_provider_prefix(name, provider)
|
||||||
|
if stripped == name and name.startswith("openai/"):
|
||||||
|
# openai-codex maps openai/gpt-5.4 -> gpt-5.4
|
||||||
|
return name.split("/", 1)[1]
|
||||||
|
return stripped
|
||||||
|
|
||||||
# --- DeepSeek: map to one of two canonical names ---
|
# --- DeepSeek: map to one of two canonical names ---
|
||||||
if provider == "deepseek":
|
if provider == "deepseek":
|
||||||
|
|||||||
@@ -1809,6 +1809,35 @@ def validate_requested_model(
|
|||||||
"message": message,
|
"message": message,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# OpenAI Codex has its own catalog path; /v1/models probing is not the right validation path.
|
||||||
|
if normalized == "openai-codex":
|
||||||
|
try:
|
||||||
|
codex_models = provider_model_ids("openai-codex")
|
||||||
|
except Exception:
|
||||||
|
codex_models = []
|
||||||
|
if codex_models:
|
||||||
|
if requested_for_lookup in set(codex_models):
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"persist": True,
|
||||||
|
"recognized": True,
|
||||||
|
"message": None,
|
||||||
|
}
|
||||||
|
suggestions = get_close_matches(requested_for_lookup, codex_models, n=3, cutoff=0.5)
|
||||||
|
suggestion_text = ""
|
||||||
|
if suggestions:
|
||||||
|
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"persist": True,
|
||||||
|
"recognized": False,
|
||||||
|
"message": (
|
||||||
|
f"Note: `{requested}` was not found in the OpenAI Codex model listing. "
|
||||||
|
f"It may still work if your account has access to it."
|
||||||
|
f"{suggestion_text}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
# Probe the live API to check if the model actually exists
|
# Probe the live API to check if the model actually exists
|
||||||
api_models = fetch_api_models(api_key, base_url)
|
api_models = fetch_api_models(api_key, base_url)
|
||||||
|
|
||||||
|
|||||||
254
tests/hermes_cli/test_cli_model_picker.py
Normal file
254
tests/hermes_cli/test_cli_model_picker.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""Tests for the interactive CLI /model picker (provider → model drill-down)."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBuffer:
|
||||||
|
def __init__(self, text="draft text"):
|
||||||
|
self.text = text
|
||||||
|
self.cursor_position = len(text)
|
||||||
|
self.reset_calls = []
|
||||||
|
|
||||||
|
def reset(self, append_to_history=False):
|
||||||
|
self.reset_calls.append(append_to_history)
|
||||||
|
self.text = ""
|
||||||
|
self.cursor_position = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _make_providers():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slug": "openrouter",
|
||||||
|
"name": "OpenRouter",
|
||||||
|
"is_current": True,
|
||||||
|
"is_user_defined": False,
|
||||||
|
"models": ["anthropic/claude-opus-4.6", "openai/gpt-5.4"],
|
||||||
|
"total_models": 2,
|
||||||
|
"source": "built-in",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "anthropic",
|
||||||
|
"name": "Anthropic",
|
||||||
|
"is_current": False,
|
||||||
|
"is_user_defined": False,
|
||||||
|
"models": ["claude-opus-4.6", "claude-sonnet-4.6"],
|
||||||
|
"total_models": 2,
|
||||||
|
"source": "built-in",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "custom:my-ollama",
|
||||||
|
"name": "My Ollama",
|
||||||
|
"is_current": False,
|
||||||
|
"is_user_defined": True,
|
||||||
|
"models": ["llama3", "mistral"],
|
||||||
|
"total_models": 2,
|
||||||
|
"source": "user-config",
|
||||||
|
"api_url": "http://localhost:11434/v1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_picker_cli(picker_return_value):
|
||||||
|
cli = MagicMock()
|
||||||
|
cli._run_curses_picker = MagicMock(return_value=picker_return_value)
|
||||||
|
cli._app = MagicMock()
|
||||||
|
cli._status_bar_visible = True
|
||||||
|
return cli
|
||||||
|
|
||||||
|
|
||||||
|
def _make_modal_cli():
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
cli = HermesCLI.__new__(HermesCLI)
|
||||||
|
cli.model = "gpt-5.4"
|
||||||
|
cli.provider = "openrouter"
|
||||||
|
cli.requested_provider = "openrouter"
|
||||||
|
cli.base_url = ""
|
||||||
|
cli.api_key = ""
|
||||||
|
cli.api_mode = ""
|
||||||
|
cli._explicit_api_key = ""
|
||||||
|
cli._explicit_base_url = ""
|
||||||
|
cli._pending_model_switch_note = None
|
||||||
|
cli._model_picker_state = None
|
||||||
|
cli._modal_input_snapshot = None
|
||||||
|
cli._status_bar_visible = True
|
||||||
|
cli._invalidate = MagicMock()
|
||||||
|
cli.agent = None
|
||||||
|
cli.config = {}
|
||||||
|
cli.console = MagicMock()
|
||||||
|
cli._app = SimpleNamespace(
|
||||||
|
current_buffer=_FakeBuffer(),
|
||||||
|
invalidate=MagicMock(),
|
||||||
|
)
|
||||||
|
return cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_selection_returns_slug_on_choice():
|
||||||
|
providers = _make_providers()
|
||||||
|
cli = _make_picker_cli(1)
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
result = HermesCLI._interactive_provider_selection(cli, providers, "gpt-5.4", "OpenRouter")
|
||||||
|
|
||||||
|
assert result == "anthropic"
|
||||||
|
cli._run_curses_picker.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_selection_returns_none_on_cancel():
|
||||||
|
providers = _make_providers()
|
||||||
|
cli = _make_picker_cli(None)
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
result = HermesCLI._interactive_provider_selection(cli, providers, "gpt-5.4", "OpenRouter")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_selection_default_is_current():
|
||||||
|
providers = _make_providers()
|
||||||
|
cli = _make_picker_cli(0)
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
HermesCLI._interactive_provider_selection(cli, providers, "gpt-5.4", "OpenRouter")
|
||||||
|
|
||||||
|
assert cli._run_curses_picker.call_args.kwargs["default_index"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_selection_returns_model_on_choice():
|
||||||
|
provider_data = _make_providers()[0]
|
||||||
|
cli = _make_picker_cli(0)
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
result = HermesCLI._interactive_model_selection(cli, provider_data["models"], provider_data)
|
||||||
|
|
||||||
|
assert result == "anthropic/claude-opus-4.6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_selection_custom_entry_prompts_for_input():
|
||||||
|
provider_data = _make_providers()[0]
|
||||||
|
cli = _make_picker_cli(2)
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
cli._prompt_text_input = MagicMock(return_value="my-custom-model")
|
||||||
|
result = HermesCLI._interactive_model_selection(cli, provider_data["models"], provider_data)
|
||||||
|
|
||||||
|
assert result == "my-custom-model"
|
||||||
|
cli._prompt_text_input.assert_called_once_with(" Enter model name: ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_selection_empty_prompts_for_manual_input():
|
||||||
|
provider_data = {
|
||||||
|
"slug": "custom:empty",
|
||||||
|
"name": "Empty Provider",
|
||||||
|
"models": [],
|
||||||
|
"total_models": 0,
|
||||||
|
}
|
||||||
|
cli = _make_picker_cli(None)
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
cli._prompt_text_input = MagicMock(return_value="my-model")
|
||||||
|
result = HermesCLI._interactive_model_selection(cli, [], provider_data)
|
||||||
|
|
||||||
|
assert result == "my-model"
|
||||||
|
cli._prompt_text_input.assert_called_once_with(" Enter model name manually (or Enter to cancel): ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_text_input_uses_run_in_terminal_when_app_active():
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
cli = _make_modal_cli()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("prompt_toolkit.application.run_in_terminal", side_effect=lambda fn: fn()) as run_mock,
|
||||||
|
patch("builtins.input", return_value="manual-value"),
|
||||||
|
):
|
||||||
|
result = HermesCLI._prompt_text_input(cli, "Enter value: ")
|
||||||
|
|
||||||
|
assert result == "manual-value"
|
||||||
|
run_mock.assert_called_once()
|
||||||
|
assert cli._status_bar_visible is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_handle_model_command_inline_uses_command_name_resolution():
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
cli = _make_modal_cli()
|
||||||
|
|
||||||
|
with patch("hermes_cli.commands.resolve_command", return_value=SimpleNamespace(name="model")):
|
||||||
|
assert HermesCLI._should_handle_model_command_inline(cli, "/model") is True
|
||||||
|
|
||||||
|
with patch("hermes_cli.commands.resolve_command", return_value=SimpleNamespace(name="help")):
|
||||||
|
assert HermesCLI._should_handle_model_command_inline(cli, "/model") is False
|
||||||
|
|
||||||
|
assert HermesCLI._should_handle_model_command_inline(cli, "/model", has_images=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_command_model_without_args_opens_modal_picker_and_captures_draft():
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
cli = _make_modal_cli()
|
||||||
|
providers = _make_providers()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("hermes_cli.model_switch.list_authenticated_providers", return_value=providers),
|
||||||
|
patch("cli._cprint"),
|
||||||
|
):
|
||||||
|
result = cli.process_command("/model")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert cli._model_picker_state is not None
|
||||||
|
assert cli._model_picker_state["stage"] == "provider"
|
||||||
|
assert cli._model_picker_state["selected"] == 0
|
||||||
|
assert cli._modal_input_snapshot == {"text": "draft text", "cursor_position": len("draft text")}
|
||||||
|
assert cli._app.current_buffer.text == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_picker_provider_then_model_selection_applies_switch_result_and_restores_draft():
|
||||||
|
from cli import HermesCLI
|
||||||
|
|
||||||
|
cli = _make_modal_cli()
|
||||||
|
providers = _make_providers()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("hermes_cli.model_switch.list_authenticated_providers", return_value=providers),
|
||||||
|
patch("cli._cprint"),
|
||||||
|
):
|
||||||
|
assert cli.process_command("/model") is True
|
||||||
|
|
||||||
|
cli._model_picker_state["selected"] = 1
|
||||||
|
with patch("hermes_cli.models.provider_model_ids", return_value=["claude-opus-4.6", "claude-sonnet-4.6"]):
|
||||||
|
HermesCLI._handle_model_picker_selection(cli)
|
||||||
|
|
||||||
|
assert cli._model_picker_state["stage"] == "model"
|
||||||
|
assert cli._model_picker_state["provider_data"]["slug"] == "anthropic"
|
||||||
|
assert cli._model_picker_state["model_list"] == ["claude-opus-4.6", "claude-sonnet-4.6"]
|
||||||
|
|
||||||
|
cli._model_picker_state["selected"] = 0
|
||||||
|
switch_result = SimpleNamespace(
|
||||||
|
success=True,
|
||||||
|
error_message=None,
|
||||||
|
new_model="claude-opus-4.6",
|
||||||
|
target_provider="anthropic",
|
||||||
|
api_key="",
|
||||||
|
base_url="",
|
||||||
|
api_mode="anthropic_messages",
|
||||||
|
provider_label="Anthropic",
|
||||||
|
model_info=None,
|
||||||
|
warning_message=None,
|
||||||
|
provider_changed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("hermes_cli.model_switch.switch_model", return_value=switch_result) as switch_mock,
|
||||||
|
patch("cli._cprint"),
|
||||||
|
):
|
||||||
|
HermesCLI._handle_model_picker_selection(cli)
|
||||||
|
|
||||||
|
assert cli._model_picker_state is None
|
||||||
|
assert cli.model == "claude-opus-4.6"
|
||||||
|
assert cli.provider == "anthropic"
|
||||||
|
assert cli.requested_provider == "anthropic"
|
||||||
|
assert cli._app.current_buffer.text == "draft text"
|
||||||
|
switch_mock.assert_called_once()
|
||||||
|
assert switch_mock.call_args.kwargs["explicit_provider"] == "anthropic"
|
||||||
Reference in New Issue
Block a user