diff --git a/cli.py b/cli.py index c0313fd24..26a223388 100644 --- a/cli.py +++ b/cli.py @@ -1817,6 +1817,7 @@ class HermesCLI: self._approval_state = None self._approval_deadline = 0 self._approval_lock = threading.Lock() + self._model_picker_state = None self._secret_state = None self._secret_deadline = 0 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'}" 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 [] try: snapshot = self._get_status_bar_snapshot() @@ -4291,6 +4292,265 @@ class HermesCLI: remaining = len(self.conversation_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): """Handle /model command — switch model for this session. @@ -4313,56 +4573,46 @@ class HermesCLI: 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 - # 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: model_display = self.model or "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: providers = list_authenticated_providers( current_provider=self.provider or "", user_providers=user_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 --provider {p['slug']})") - else: - _cprint(f" (no models listed)") - _cprint("") - else: - _cprint(" No authenticated providers found.") - _cprint("") except Exception: - pass + providers = [] - # Aliases - from hermes_cli.model_switch import MODEL_ALIASES - alias_list = ", ".join(sorted(MODEL_ALIASES.keys())) - _cprint(f" Aliases: {alias_list}") - _cprint("") - _cprint(" /model switch model") - _cprint(" /model --provider switch provider") - _cprint(" /model --global persist to config") + if not providers: + _cprint(" No authenticated providers found.") + _cprint("") + _cprint(" /model switch model") + _cprint(" /model --provider switch provider") + return + + self._open_model_picker( + providers, + model_display, + provider_display, + user_provs=user_provs, + custom_provs=custom_provs, + ) return # Perform the switch @@ -4470,6 +4720,18 @@ class HermesCLI: else: _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): """Show current model + provider and list all authenticated providers. @@ -7692,7 +7954,8 @@ class HermesCLI: secret_widget, approval_widget, clarify_widget, - spinner_widget, + model_picker_widget=None, + spinner_widget=None, spacer, status_bar, input_rule_top, @@ -7709,21 +7972,24 @@ class HermesCLI: ordering. """ return [ - Window(height=0), - sudo_widget, - secret_widget, - approval_widget, - clarify_widget, - spinner_widget, - spacer, - *self._get_extra_tui_widgets(), - status_bar, - input_rule_top, - image_bar, - input_area, - input_rule_bot, - voice_status_bar, - completions_menu, + item for item in [ + Window(height=0), + sudo_widget, + secret_widget, + approval_widget, + clarify_widget, + model_picker_widget, + spinner_widget, + spacer, + *self._get_extra_tui_widgets(), + status_bar, + input_rule_top, + image_bar, + input_area, + input_rule_bot, + voice_status_bar, + completions_menu, + ] if item is not None ] def run(self): @@ -7884,6 +8150,12 @@ class HermesCLI: event.app.invalidate() 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 --- if self._clarify_freetext and self._clarify_state: text = event.app.current_buffer.text.strip() @@ -7914,6 +8186,16 @@ class HermesCLI: text = event.app.current_buffer.text.strip() has_images = bool(self._attached_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 images = list(self._attached_images) self._attached_images.clear() @@ -8017,12 +8299,31 @@ class HermesCLI: self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1) 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 --- # 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, # history browsing when on the first/last line (or single-line input). _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) @@ -8088,6 +8389,13 @@ class HermesCLI: event.app.invalidate() 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 if self._clarify_state: self._clarify_state["response_queue"].put( @@ -8140,7 +8448,7 @@ class HermesCLI: 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." def _suspend(): - os.write(1, msg.encode("utf-8", errors="replace")) + os.write(1, msg.encode()) os.kill(0, _sig.SIGTSTP) run_in_terminal(_suspend) @@ -8705,6 +9013,60 @@ class HermesCLI: 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. # On narrow/mobile terminals we keep the top separator for structure but # hide the bottom one to recover a full row for conversation content. @@ -8780,6 +9142,7 @@ class HermesCLI: secret_widget=secret_widget, approval_widget=approval_widget, clarify_widget=clarify_widget, + model_picker_widget=model_picker_widget, spinner_widget=spinner_widget, spacer=spacer, status_bar=status_bar, diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 9cebaf60f..4880171fd 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -287,6 +287,129 @@ def _radio_numbered_fallback( 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( title: str, items: List[str], diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 8c0c30fbf..68e8dc898 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -74,13 +74,13 @@ _DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({ "copilot", "copilot-acp", + "openai-codex", }) # Providers whose native naming is authoritative -- pass through unchanged. _AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({ "gemini", "huggingface", - "openai-codex", }) # 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 --- 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 --- if provider == "deepseek": diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 17c1072db..ae4146415 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1809,6 +1809,35 @@ def validate_requested_model( "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 api_models = fetch_api_models(api_key, base_url) diff --git a/tests/hermes_cli/test_cli_model_picker.py b/tests/hermes_cli/test_cli_model_picker.py new file mode 100644 index 000000000..1fe9fe51a --- /dev/null +++ b/tests/hermes_cli/test_cli_model_picker.py @@ -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"