diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index b21ecc7b..19aefa12 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -109,6 +109,7 @@ _UI_BORDER = "#4E5254" _UI_TEXT = "#A9B7C6" _UI_MUTED = "#80868B" _UI_SUCCESS = "#6AAB73" +_PROMPT_ESCAPE_TIMEOUT_SECONDS = 0.05 _CHANNEL_LOGIN_CHOICE = "Login with QR/link" _CHANNEL_ADVANCED_CHOICE = "Edit advanced settings" @@ -138,6 +139,8 @@ def _select_with_back( The selected choice string if user confirmed None if user cancelled (Ctrl+C) """ + import shutil + from prompt_toolkit.application import Application from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.keys import Keys @@ -158,11 +161,15 @@ def _select_with_back( # State holder for the result state: dict[str, str | None | object] = {"result": None} + terminal_lines = shutil.get_terminal_size((80, 24)).lines + visible_count = min(len(choices), max(1, terminal_lines - 3)) # Build menu items (uses closure over selected_index) def get_menu_text(): items = [] - for i, choice in enumerate(choices): + start, end = _choice_viewport(selected_index, len(choices), visible_count) + for i in range(start, end): + choice = choices[i] if i == selected_index: items.append(("class:selected", f"> {choice}\n")) else: @@ -170,11 +177,15 @@ def _select_with_back( return items # Create layout - menu_control = FormattedTextControl(get_menu_text) - menu_window = Window(content=menu_control, height=len(choices)) + menu_control = FormattedTextControl(get_menu_text, show_cursor=False) + menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True) - prompt_control = FormattedTextControl(lambda: [("class:question", f"> {prompt}")]) - prompt_window = Window(content=prompt_control, height=1) + def get_prompt_text(): + suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else "" + return [("class:question", f"{prompt}{suffix}")] + + prompt_control = FormattedTextControl(get_prompt_text, show_cursor=False) + prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True) layout = Layout(HSplit([prompt_window, menu_window])) @@ -220,6 +231,8 @@ def _select_with_back( }) app = Application(layout=layout, key_bindings=bindings, style=style) + app.ttimeoutlen = 0.05 + app.timeoutlen = 0.05 try: app.run() except Exception: @@ -228,6 +241,18 @@ def _select_with_back( return state["result"] + +def _choice_viewport(selected_index: int, total: int, visible_count: int) -> tuple[int, int]: + """Return the visible slice for a long terminal menu.""" + if total <= 0: + return 0, 0 + visible_count = max(1, min(visible_count, total)) + selected_index = max(0, min(selected_index, total - 1)) + half = visible_count // 2 + start = selected_index - half + start = max(0, min(start, total - visible_count)) + return start, start + visible_count + # --- Type Introspection --- @@ -474,14 +499,44 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None: ).ask() +def _input_back_key_bindings(): + """Return key bindings that make Escape behave like a local back action.""" + from prompt_toolkit.key_binding import KeyBindings + + bindings = KeyBindings() + + @bindings.add("escape") + def _escape(event): + event.app.exit(result=_BACK_PRESSED) + + return bindings + + +def _ask_prompt(prompt): + """Ask a questionary prompt with responsive Escape handling.""" + app = getattr(prompt, "application", None) + if app is not None: + if hasattr(app, "ttimeoutlen"): + app.ttimeoutlen = _PROMPT_ESCAPE_TIMEOUT_SECONDS + if hasattr(app, "timeoutlen"): + app.timeoutlen = _PROMPT_ESCAPE_TIMEOUT_SECONDS + return prompt.ask() + + def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any: """Get text input and parse based on field type.""" default = _format_value_for_input(current, field_type) - value = _get_questionary().text(f"{display_name}:", default=default).ask() + value = _ask_prompt( + _get_questionary().text( + f"{display_name}:", + default=default, + key_bindings=_input_back_key_bindings(), + ) + ) - if value is None: - return None + if value is _BACK_PRESSED or value is None: + return None if value is None else _BACK_PRESSED if field_type == "int": try: @@ -519,14 +574,14 @@ def _input_text(display_name: str, current: Any, field_type: str, field_info=Non return value -def _input_secret(display_name: str) -> str | None: +def _input_secret(display_name: str) -> str | None | object: """Get a secret value without echoing it when questionary supports password input.""" prompt_factory = getattr(_get_questionary(), "password", None) if prompt_factory is None: prompt_factory = _get_questionary().text - value = prompt_factory(f"{display_name}:").ask() - if value is None: - return None + value = _ask_prompt(prompt_factory(f"{display_name}:", key_bindings=_input_back_key_bindings())) + if value is _BACK_PRESSED or value is None: + return None if value is None else _BACK_PRESSED return str(value).strip() @@ -560,7 +615,7 @@ def _get_current_provider(model: BaseModel) -> str: def _input_model_with_autocomplete( display_name: str, current: Any, provider: str -) -> str | None: +) -> str | None | object: """Get model input with autocomplete suggestions. """ @@ -587,20 +642,25 @@ def _input_model_with_autocomplete( display=model, ) - value = _get_questionary().autocomplete( - f"{display_name}:", - choices=[""], # Placeholder, actual completions from completer - completer=DynamicModelCompleter(provider), - default=default, - qmark=">", - ).ask() + value = _ask_prompt( + _get_questionary().autocomplete( + f"{display_name}:", + choices=[""], # Placeholder, actual completions from completer + completer=DynamicModelCompleter(provider), + default=default, + key_bindings=_input_back_key_bindings(), + qmark=">", + ) + ) - return value if value is not None else None + if value is _BACK_PRESSED or value is None: + return None if value is None else _BACK_PRESSED + return value def _input_context_window_with_recommendation( display_name: str, current: Any, model_obj: BaseModel -) -> int | None: +) -> int | None | object: """Get context window input with option to fetch recommended value.""" current_val = current if current else "" @@ -645,8 +705,11 @@ def _input_context_window_with_recommendation( value = _get_questionary().text( f"{display_name}:", default=str(current_val) if current_val else "", + key_bindings=_input_back_key_bindings(), ).ask() + if value is _BACK_PRESSED: + return _BACK_PRESSED if value is None or value == "": return None @@ -663,6 +726,8 @@ def _handle_model_field( """Handle the 'model' field with autocomplete and context-window auto-fill.""" provider = _get_current_provider(working_model) new_value = _input_model_with_autocomplete(field_display, current_value, provider) + if new_value is _BACK_PRESSED: + return if new_value is not None and new_value != current_value: setattr(working_model, field_name, new_value) _try_auto_fill_context_window(working_model, new_value) @@ -675,6 +740,8 @@ def _handle_context_window_field( new_value = _input_context_window_with_recommendation( field_display, current_value, working_model ) + if new_value is _BACK_PRESSED: + return if new_value is not None: setattr(working_model, field_name, new_value) @@ -902,6 +969,8 @@ def _configure_pydantic_model( new_value = _input_bool(field_display, current_value) else: new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info) + if new_value is _BACK_PRESSED: + continue if new_value is not None: # Normalize empty string to None for optional string fields so that # clearing an api_key / api_base actually removes the value. @@ -1523,7 +1592,7 @@ def _select_quick_start_api_base( provider_name: str, provider_display: str, info: _QuickStartProviderInfo | None, -) -> tuple[str, bool] | None: +) -> tuple[str, bool] | None | object: """Return the api_base and whether the user explicitly selected or entered it.""" endpoint_choices = _QUICK_START_ENDPOINT_CHOICES.get(provider_name) if endpoint_choices: @@ -1533,7 +1602,9 @@ def _select_quick_start_api_base( list(choices) + ["<- Back"], default=endpoint_choices[0].label, ) - if answer is _BACK_PRESSED or answer is None or answer == "<- Back": + if answer is _BACK_PRESSED or answer == "<- Back": + return _BACK_PRESSED + if answer is None: return None assert isinstance(answer, str) return choices[answer], True @@ -1547,6 +1618,8 @@ def _select_quick_start_api_base( api_base, "str", ) + if base_answer is _BACK_PRESSED: + return _BACK_PRESSED if base_answer is None: return None api_base = base_answer.strip().rstrip("/") @@ -1556,73 +1629,82 @@ def _select_quick_start_api_base( return api_base, True -def _configure_quick_start_provider(config: Config) -> bool: +def _configure_quick_start_provider(config: Config) -> bool | object: """Configure the beginner path from provider credentials and model.""" - _show_quick_start_progress(1) + while True: + _show_quick_start_progress(1) - provider_choices = _get_quick_start_provider_choices() - answer = _select_with_back( - "Which provider do you want to use?", - list(provider_choices) + ["<- Back"], - ) - if answer is _BACK_PRESSED or answer is None or answer == "<- Back": - return False - assert isinstance(answer, str) - provider_name = provider_choices[answer] - provider_info = _get_quick_start_provider_info().get(provider_name) + provider_choices = _get_quick_start_provider_choices() + answer = _select_with_back( + "Which provider do you want to use?", + list(provider_choices) + ["<- Back"], + ) + if answer is _BACK_PRESSED or answer is None or answer == "<- Back": + return _BACK_PRESSED + assert isinstance(answer, str) + provider_name = provider_choices[answer] + provider_info = _get_quick_start_provider_info().get(provider_name) - api_base = provider_info.default_api_base if provider_info else "" - base_was_prompted = False - if provider_name in _QUICK_START_ENDPOINT_CHOICES: - api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info) - if api_base_result is None: - return False - api_base, base_was_prompted = api_base_result + api_base = provider_info.default_api_base if provider_info else "" + base_was_prompted = False + if provider_name in _QUICK_START_ENDPOINT_CHOICES: + api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info) + if api_base_result is _BACK_PRESSED: + continue + if api_base_result is None: + return False + api_base, base_was_prompted = api_base_result - api_key: str | None = None - if _quick_start_requires_api_key(provider_name, provider_info): - api_key = _input_text(f"{answer} API key", "", "str") - if api_key is None: - return False - api_key = api_key.strip() - if not api_key: - console.print("[yellow]! API key is required for Quick Start[/yellow]") + api_key: str | None = None + if _quick_start_requires_api_key(provider_name, provider_info): + api_key = _input_text(f"{answer} API key", "", "str") + if api_key is _BACK_PRESSED: + continue + if api_key is None: + return False + api_key = api_key.strip() + if not api_key: + console.print("[yellow]! API key is required for Quick Start[/yellow]") + return False + + if ( + provider_name not in _QUICK_START_ENDPOINT_CHOICES + and _quick_start_requires_base_url(provider_name, provider_info) + ): + api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info) + if api_base_result is _BACK_PRESSED: + continue + if api_base_result is None: + return False + api_base, base_was_prompted = api_base_result + + provider_config = getattr(config.providers, provider_name, None) + if provider_config is None: + console.print(f"[red]Unknown provider: {provider_name}[/red]") return False - if ( - provider_name not in _QUICK_START_ENDPOINT_CHOICES - and _quick_start_requires_base_url(provider_name, provider_info) - ): - api_base_result = _select_quick_start_api_base(provider_name, answer, provider_info) - if api_base_result is None: + model = _input_model_with_autocomplete("Model ID", "", provider_name) + if model is _BACK_PRESSED: + continue + model = (model or "").strip() + if not model: + console.print("[yellow]! Model ID is required for Quick Start[/yellow]") return False - api_base, base_was_prompted = api_base_result - provider_config = getattr(config.providers, provider_name, None) - if provider_config is None: - console.print(f"[red]Unknown provider: {provider_name}[/red]") - return False + if api_key is not None: + provider_config.api_key = api_key + if api_base: + if base_was_prompted: + provider_config.api_base = api_base + elif not provider_config.api_base: + provider_config.api_base = api_base - model = _input_model_with_autocomplete("Model ID", "", provider_name) - model = (model or "").strip() - if not model: - console.print("[yellow]! Model ID is required for Quick Start[/yellow]") - return False - - if api_key is not None: - provider_config.api_key = api_key - if api_base: - if base_was_prompted: - provider_config.api_base = api_base - elif not provider_config.api_base: - provider_config.api_base = api_base - - _set_primary_quick_start_preset( - config, - provider_name, - model, - ) - return True + _set_primary_quick_start_preset( + config, + provider_name, + model, + ) + return True def _enable_quick_start_websocket_defaults(config: Config) -> bool: @@ -1635,17 +1717,23 @@ def _enable_quick_start_websocket_defaults(config: Config) -> bool: f"[{_UI_MUTED}]This lets the browser UI at http://127.0.0.1:8765 connect to nanobot.[/]" ) console.print() - answer = _get_questionary().confirm( - "Enable WebSocket channel now?", - default=True, - ).ask() - if not answer: - console.print("[yellow]! Quick Start needs the WebSocket channel for the local WebUI[/yellow]") - return False - webui_secret = _input_secret("Set a WebUI password") - if not webui_secret: - console.print("[yellow]! WebUI password is required when enabling WebSocket[/yellow]") - return False + while True: + answer = _get_questionary().confirm( + "Enable WebSocket channel now?", + default=True, + ).ask() + if not answer: + console.print( + "[yellow]! Quick Start needs the WebSocket channel for the local WebUI[/yellow]" + ) + return False + webui_secret = _input_secret("Set a WebUI password") + if webui_secret is _BACK_PRESSED: + continue + if not webui_secret: + console.print("[yellow]! WebUI password is required when enabling WebSocket[/yellow]") + return False + break config_cls = _get_channel_config_class("websocket") if config_cls is None: @@ -1701,7 +1789,10 @@ def _configure_quick_start(config: Config) -> bool: "Choose provider endpoint, add credentials and model, then enable the local WebUI channel.", ) draft = config.model_copy(deep=True) - if not _configure_quick_start_provider(draft): + provider_result = _configure_quick_start_provider(draft) + if provider_result is _BACK_PRESSED: + return False + if not provider_result: _pause() return False if not _enable_quick_start_websocket_defaults(draft): @@ -1761,6 +1852,18 @@ def _get_main_menu_choices(has_unsaved_changes: bool) -> list[str]: def _configure_advanced_settings(config: Config) -> None: """Show lower-frequency setup options behind one advanced menu.""" last_choice: str | None = None + choices = [ + "[P] LLM Provider", + "[M] Model Presets", + "[C] Chat Channel", + "[H] Channel Common", + "[A] Agent Settings", + "[I] API Server", + "[G] Gateway", + "[T] Tools", + "[V] View Configuration Summary", + "<- Back", + ] while True: try: console.clear() @@ -1768,27 +1871,15 @@ def _configure_advanced_settings(config: Config) -> None: "Advanced Settings", "Use these when the default API-key setup is not enough.", ) - answer = _get_questionary().select( + answer = _select_with_back( "What would you like to configure?", - choices=[ - "[P] LLM Provider", - "[M] Model Presets", - "[C] Chat Channel", - "[H] Channel Common", - "[A] Agent Settings", - "[I] API Server", - "[G] Gateway", - "[T] Tools", - "[V] View Configuration Summary", - "<- Back", - ], + choices, default=last_choice, - qmark=">", - ).ask() + ) except KeyboardInterrupt: break - if answer is None or answer == "<- Back": + if answer is _BACK_PRESSED or answer is None or answer == "<- Back": break _advanced_dispatch = { @@ -1830,22 +1921,19 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: config = base_config.model_copy(deep=True) _sync_preset_cache(config) - last_main_choice: str | None = None while True: console.clear() _show_main_menu_header() try: - answer = _get_questionary().select( + answer = _select_with_back( "What would you like to do?", - choices=_get_main_menu_choices(_has_unsaved_changes(original_config, config)), - default=last_main_choice, - qmark=">", - ).ask() + _get_main_menu_choices(_has_unsaved_changes(original_config, config)), + ) except KeyboardInterrupt: answer = None - if answer is None: + if answer is _BACK_PRESSED or answer is None: action = _prompt_main_menu_exit(_has_unsaved_changes(original_config, config)) if action == "save": return OnboardResult(config=config, should_save=True) @@ -1863,5 +1951,4 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: if answer in {"[X] Exit", "[X] Exit Without Saving"}: return OnboardResult(config=original_config, should_save=False) if answer == "[A] Advanced Settings": - last_main_choice = answer _configure_advanced_settings(config) diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 92799bea..2067f8d6 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -520,24 +520,18 @@ class TestRunOnboardExitBehavior: ] ) - class FakePrompt: - def __init__(self, response): - self.response = response - - def ask(self): - if isinstance(self.response, BaseException): - raise self.response - return self.response - - def fake_select(*_args, **_kwargs): - return FakePrompt(next(responses)) + def fake_select_with_back(*_args, **_kwargs): + response = next(responses) + if isinstance(response, BaseException): + raise response + return response def fake_configure_general_settings(config, section): if section == "Agent Settings": config.agents.defaults.model = "test/provider-model" monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) - monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings) result = run_onboard(initial_config=initial_config) @@ -868,6 +862,22 @@ class TestApiServerRegistration: class TestMainMenuUpdate: """Tests for main menu including new Channel Common and API Server items.""" + def test_choice_viewport_keeps_long_menus_within_terminal_height(self): + """Long provider menus should render as a bounded scrolling slice.""" + assert onboard_wizard._choice_viewport(selected_index=0, total=20, visible_count=5) == (0, 5) + assert onboard_wizard._choice_viewport(selected_index=10, total=20, visible_count=5) == ( + 8, + 13, + ) + assert onboard_wizard._choice_viewport(selected_index=19, total=20, visible_count=5) == ( + 15, + 20, + ) + + def test_choice_viewport_handles_tiny_terminals(self): + """A one-row menu is still usable instead of failing as window-too-small.""" + assert onboard_wizard._choice_viewport(selected_index=3, total=5, visible_count=0) == (3, 4) + def test_main_menu_hides_save_actions_until_needed(self): """The first screen should not show save or summary actions before edits.""" from nanobot.cli.onboard import _get_main_menu_choices @@ -893,22 +903,15 @@ class TestMainMenuUpdate: "[Q] Quick Start", ]) - class FakePrompt: - def __init__(self, response): - self.response = response - - def ask(self): - return self.response - - def fake_select(*_args, **_kwargs): - return FakePrompt(next(responses)) + def fake_select_with_back(*_args, **_kwargs): + return next(responses) def fake_quick_start(config): config.agents.defaults.bot_name = "quickbot" return True monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) - monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) monkeypatch.setattr(onboard_wizard, "_configure_quick_start", fake_quick_start) result = run_onboard(initial_config=initial_config) @@ -916,6 +919,46 @@ class TestMainMenuUpdate: assert result.should_save is True assert result.config.agents.defaults.bot_name == "quickbot" + def test_main_menu_default_resets_after_returning_from_advanced(self, monkeypatch): + """Returning from Advanced should not leave its item visually selected.""" + initial_config = Config() + responses = iter([ + "[A] Advanced Settings", + "<- Back", + "[X] Exit", + ]) + main_defaults: list[str | None] = [] + + def fake_select_with_back(prompt, _choices, default=None): + if prompt == "What would you like to do?": + main_defaults.append(default) + return next(responses) + + monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) + monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) + + result = run_onboard(initial_config=initial_config) + + assert result.should_save is False + assert main_defaults == [None, None] + + def test_ask_prompt_shortens_escape_timeout(self): + """Questionary text prompts should not wait the default timeout on Escape.""" + + class FakePrompt: + def __init__(self): + self.application = SimpleNamespace(ttimeoutlen=0.5, timeoutlen=1.0) + + def ask(self): + return "ok" + + prompt = FakePrompt() + + assert onboard_wizard._ask_prompt(prompt) == "ok" + assert prompt.application.ttimeoutlen == onboard_wizard._PROMPT_ESCAPE_TIMEOUT_SECONDS + assert prompt.application.timeoutlen == onboard_wizard._PROMPT_ESCAPE_TIMEOUT_SECONDS + def test_quick_start_provider_choices_include_all_chat_providers(self): """Quick Start should be driven by the provider registry, not a short allowlist.""" from nanobot.providers.registry import PROVIDERS @@ -983,6 +1026,45 @@ class TestMainMenuUpdate: assert websocket["websocketRequiresToken"] is True assert websocket["tokenIssueSecret"] == "webui-secret" + def test_quick_start_provider_menu_escape_returns_back(self, monkeypatch): + """Esc from the first Quick Start menu should return to the main menu.""" + config = Config() + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr( + onboard_wizard, + "_select_with_back", + lambda *a, **kw: onboard_wizard._BACK_PRESSED, + ) + + assert onboard_wizard._configure_quick_start_provider(config) is onboard_wizard._BACK_PRESSED + assert "primary" not in config.model_presets + + def test_quick_start_provider_back_skips_pause(self, monkeypatch): + """Returning from Quick Start should not require an extra Enter key press.""" + config = Config() + pause_messages: list[str] = [] + + def fail_websocket_defaults(*_args, **_kwargs): + raise AssertionError("Back navigation should not continue Quick Start") + + monkeypatch.setattr(onboard_wizard.console, "clear", lambda: None) + monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None) + monkeypatch.setattr( + onboard_wizard, + "_configure_quick_start_provider", + lambda *_args: onboard_wizard._BACK_PRESSED, + ) + monkeypatch.setattr( + onboard_wizard, + "_enable_quick_start_websocket_defaults", + fail_websocket_defaults, + ) + monkeypatch.setattr(onboard_wizard, "_pause", lambda message="": pause_messages.append(message)) + + assert onboard_wizard._configure_quick_start(config) is False + assert pause_messages == [] + def test_quick_start_websocket_decline_rolls_back_provider_defaults(self, monkeypatch): """A failed WebSocket step should not leave saveable Quick Start defaults behind.""" config = Config() @@ -1085,6 +1167,34 @@ class TestMainMenuUpdate: assert config.model_presets["primary"].provider == "openai" assert config.model_presets["primary"].model == "gpt-4o-mini" + def test_quick_start_api_key_escape_returns_to_provider_choice(self, monkeypatch): + """Esc from an API-key prompt should go back to provider selection.""" + config = Config() + provider_answers = iter(["DeepSeek", "OpenAI"]) + api_key_answers = iter([onboard_wizard._BACK_PRESSED, "sk-openai-test"]) + selected_providers: list[str] = [] + + def fake_select(*_args, **_kwargs): + selected = next(provider_answers) + selected_providers.append(selected) + return selected + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select) + monkeypatch.setattr(onboard_wizard, "_input_text", lambda *a, **kw: next(api_key_answers)) + monkeypatch.setattr( + onboard_wizard, + "_input_model_with_autocomplete", + lambda *a, **kw: "gpt-4o-mini", + ) + + assert onboard_wizard._configure_quick_start_provider(config) is True + + assert selected_providers == ["DeepSeek", "OpenAI"] + assert config.providers.deepseek.api_key is None + assert config.providers.openai.api_key == "sk-openai-test" + assert config.model_presets["primary"].provider == "openai" + def test_quick_start_zhipu_coding_plan_uses_coding_base_url(self, monkeypatch): """Zhipu Coding Plan should not use the standard Zhipu base URL.""" config = Config() @@ -1441,24 +1551,18 @@ class TestMainMenuUpdate: "[S] Save and Exit", ]) - class FakePrompt: - def __init__(self, response): - self.response = response - - def ask(self): - if isinstance(self.response, BaseException): - raise self.response - return self.response - - def fake_select(*_args, **_kwargs): - return FakePrompt(next(responses)) + def fake_select_with_back(*_args, **_kwargs): + response = next(responses) + if isinstance(response, BaseException): + raise response + return response def fake_configure_general_settings(config, section): if section == "Channel Common": config.channels.send_tool_hints = True monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) - monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings) result = run_onboard(initial_config=initial_config) @@ -1477,24 +1581,18 @@ class TestMainMenuUpdate: "[S] Save and Exit", ]) - class FakePrompt: - def __init__(self, response): - self.response = response - - def ask(self): - if isinstance(self.response, BaseException): - raise self.response - return self.response - - def fake_select(*_args, **_kwargs): - return FakePrompt(next(responses)) + def fake_select_with_back(*_args, **_kwargs): + response = next(responses) + if isinstance(response, BaseException): + raise response + return response def fake_configure_general_settings(config, section): if section == "API Server": config.api.port = 9999 monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) - monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings) result = run_onboard(initial_config=initial_config) @@ -1514,23 +1612,17 @@ class TestMainMenuUpdate: "[X] Exit", ]) - class FakePrompt: - def __init__(self, response): - self.response = response - - def ask(self): - if isinstance(self.response, BaseException): - raise self.response - return self.response - - def fake_select(*_args, **_kwargs): - return FakePrompt(next(responses)) + def fake_select_with_back(*_args, **_kwargs): + response = next(responses) + if isinstance(response, BaseException): + raise response + return response def fake_pause(): pause_called["n"] += 1 monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) - monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) # _pause is called inside _show_summary, so we patch it there monkeypatch.setattr(onboard_wizard, "_pause", fake_pause) # Suppress summary output but still call _pause @@ -1569,6 +1661,19 @@ class TestInputTextEmptyString: result = _input_text("Name", "old", "str") assert result is None + def test_escape_returns_back_pressed(self, monkeypatch): + """_input_text should preserve the local back sentinel.""" + monkeypatch.setattr( + onboard_wizard, + "_get_questionary", + lambda: SimpleNamespace( + text=lambda *a, **kw: SimpleNamespace(ask=lambda: onboard_wizard._BACK_PRESSED) + ), + ) + + result = _input_text("Name", "old", "str") + assert result is onboard_wizard._BACK_PRESSED + class TestIsStrOrNone: """Tests for _is_str_or_none helper.""" @@ -1697,13 +1802,8 @@ class TestModelPresetWizard: self.response = response def ask(self): - if isinstance(self.response, BaseException): - raise self.response return self.response - def fake_select(*_args, **_kwargs): - return FakePrompt(next(responses)) - def fake_text(*_args, **_kwargs): return FakePrompt(next(responses)) @@ -1714,9 +1814,7 @@ class TestModelPresetWizard: return next(responses) monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) - monkeypatch.setattr( - onboard_wizard, "questionary", SimpleNamespace(select=fake_select, text=fake_text) - ) + monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(text=fake_text)) monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fake_configure) monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None) monkeypatch.setattr(onboard_wizard, "console", SimpleNamespace(clear=lambda: None)) @@ -1829,17 +1927,11 @@ class TestModelPresetWizard: "[S] Save and Exit", ]) - class FakePrompt: - def __init__(self, response): - self.response = response - - def ask(self): - if isinstance(self.response, BaseException): - raise self.response - return self.response - - def fake_select(*_args, **_kwargs): - return FakePrompt(next(responses)) + def fake_select_with_back(*_args, **_kwargs): + response = next(responses) + if isinstance(response, BaseException): + raise response + return response preset_mutated = {"n": 0} @@ -1847,7 +1939,7 @@ class TestModelPresetWizard: preset_mutated["n"] += 1 config.model_presets["test"] = ModelPresetConfig(model="gpt-test") - monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select)) + monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back) monkeypatch.setattr(onboard_wizard, "_configure_model_presets", fake_configure_model_presets) monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None) monkeypatch.setattr(onboard_wizard, "_show_section_header", lambda *a, **kw: None)