fix: polish onboard wizard keyboard navigation

This commit is contained in:
Xubin Ren
2026-06-22 13:04:05 +08:00
parent 9b52202f4a
commit 351aabd512
2 changed files with 375 additions and 196 deletions
+170 -78
View File
@@ -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)