feat(onboard): add field hints and Escape/Left navigation

- Add `_SELECT_FIELD_HINTS` for select fields with predefined choices
  (e.g., reasoning_effort: low/medium/high with hint text)
- Add `_select_with_back()` using prompt_toolkit for custom key bindings
- Support Escape and Left arrow keys to go back in menus
- Apply to field config, provider selection, and channel selection menus
This commit is contained in:
chengyongru
2026-03-16 22:24:17 +08:00
committed by Xubin Ren
parent 814c72eac3
commit 606e8fa450

View File

@@ -21,6 +21,127 @@ from nanobot.config.schema import Config
console = Console() console = Console()
# --- Field Hints for Select Fields ---
# Maps field names to (choices, hint_text)
# To add a new select field with hints, add an entry:
# "field_name": (["choice1", "choice2", ...], "hint text for the field")
_SELECT_FIELD_HINTS: dict[str, tuple[list[str], str]] = {
"reasoning_effort": (
["low", "medium", "high"],
"low / medium / high — enables LLM thinking mode",
),
}
# --- Key Bindings for Navigation ---
_BACK_PRESSED = object() # Sentinel value for back navigation
def _select_with_back(
prompt: str, choices: list[str], default: str | None = None
) -> str | None | object:
"""Select with Escape/Left arrow support for going back.
Args:
prompt: The prompt text to display.
choices: List of choices to select from. Must not be empty.
default: The default choice to pre-select. If not in choices, first item is used.
Returns:
_BACK_PRESSED sentinel if user pressed Escape or Left arrow
The selected choice string if user confirmed
None if user cancelled (Ctrl+C)
"""
from prompt_toolkit.application import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.containers import HSplit, Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.styles import Style
# Validate choices
if not choices:
logger.warning("Empty choices list provided to _select_with_back")
return None
# Find default index
selected_index = 0
if default and default in choices:
selected_index = choices.index(default)
# State holder for the result
state: dict[str, str | None | object] = {"result": None}
# Build menu items (uses closure over selected_index)
def get_menu_text():
items = []
for i, choice in enumerate(choices):
if i == selected_index:
items.append(("class:selected", f"{choice}\n"))
else:
items.append(("", f" {choice}\n"))
return items
# Create layout
menu_control = FormattedTextControl(get_menu_text)
menu_window = Window(content=menu_control, height=len(choices))
prompt_control = FormattedTextControl(lambda: [("class:question", f"{prompt}")])
prompt_window = Window(content=prompt_control, height=1)
layout = Layout(HSplit([prompt_window, menu_window]))
# Key bindings
bindings = KeyBindings()
@bindings.add(Keys.Up)
def _up(event):
nonlocal selected_index
selected_index = (selected_index - 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Down)
def _down(event):
nonlocal selected_index
selected_index = (selected_index + 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Enter)
def _enter(event):
state["result"] = choices[selected_index]
event.app.exit()
@bindings.add("escape")
def _escape(event):
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.Left)
def _left(event):
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.ControlC)
def _ctrl_c(event):
state["result"] = None
event.app.exit()
# Style
style = Style.from_dict({
"selected": "fg:green bold",
"question": "fg:cyan",
})
app = Application(layout=layout, key_bindings=bindings, style=style)
try:
app.run()
except Exception:
logger.exception("Error in select prompt")
return None
return state["result"]
# --- Type Introspection --- # --- Type Introspection ---
@@ -365,11 +486,13 @@ def _configure_pydantic_model(
_show_config_panel(display_name, model, fields) _show_config_panel(display_name, model, fields)
choices = get_choices() choices = get_choices()
answer = questionary.select( answer = _select_with_back("Select field to configure:", choices)
"Select field to configure:",
choices=choices, if answer is _BACK_PRESSED:
qmark="", # User pressed Escape or Left arrow - go back
).ask() if finalize_hook:
finalize_hook(model)
break
if answer == "✓ Done" or answer is None: if answer == "✓ Done" or answer is None:
if finalize_hook: if finalize_hook:
@@ -414,6 +537,20 @@ def _configure_pydantic_model(
setattr(model, field_name, new_value) setattr(model, field_name, new_value)
continue continue
# Special handling for select fields with hints (e.g., reasoning_effort)
if field_name in _SELECT_FIELD_HINTS:
choices_list, hint = _SELECT_FIELD_HINTS[field_name]
select_choices = choices_list + ["(clear/unset)"]
console.print(f"[dim] Hint: {hint}[/dim]")
new_value = _select_with_back(field_display, select_choices, default=current_value or select_choices[0])
if new_value is _BACK_PRESSED:
continue
if new_value == "(clear/unset)":
setattr(model, field_name, None)
elif new_value is not None:
setattr(model, field_name, new_value)
continue
if field_type == "bool": if field_type == "bool":
new_value = _input_bool(field_display, current_value) new_value = _input_bool(field_display, current_value)
if new_value is not None: if new_value is not None:
@@ -524,15 +661,13 @@ def _configure_providers(config: Config) -> None:
while True: while True:
try: try:
choices = get_provider_choices() choices = get_provider_choices()
answer = questionary.select( answer = _select_with_back("Select provider:", choices)
"Select provider:",
choices=choices,
qmark="",
).ask()
if answer is None or answer == "← Back": if answer is _BACK_PRESSED or answer is None or answer == "← Back":
break break
# Type guard: answer is now guaranteed to be a string
assert isinstance(answer, str)
# Extract provider name from choice (remove " ✓" suffix if present) # Extract provider name from choice (remove " ✓" suffix if present)
provider_name = answer.replace("", "") provider_name = answer.replace("", "")
# Find the actual provider key from display names # Find the actual provider key from display names
@@ -632,15 +767,13 @@ def _configure_channels(config: Config) -> None:
while True: while True:
try: try:
answer = questionary.select( answer = _select_with_back("Select channel:", choices)
"Select channel:",
choices=choices,
qmark="",
).ask()
if answer is None or answer == "← Back": if answer is _BACK_PRESSED or answer is None or answer == "← Back":
break break
# Type guard: answer is now guaranteed to be a string
assert isinstance(answer, str)
_configure_channel(config, answer) _configure_channel(config, answer)
except KeyboardInterrupt: except KeyboardInterrupt:
console.print("\n[dim]Returning to main menu...[/dim]") console.print("\n[dim]Returning to main menu...[/dim]")