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:
@@ -21,6 +21,127 @@ from nanobot.config.schema import Config
|
||||
|
||||
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 ---
|
||||
|
||||
|
||||
@@ -365,11 +486,13 @@ def _configure_pydantic_model(
|
||||
_show_config_panel(display_name, model, fields)
|
||||
choices = get_choices()
|
||||
|
||||
answer = questionary.select(
|
||||
"Select field to configure:",
|
||||
choices=choices,
|
||||
qmark="→",
|
||||
).ask()
|
||||
answer = _select_with_back("Select field to configure:", choices)
|
||||
|
||||
if answer is _BACK_PRESSED:
|
||||
# User pressed Escape or Left arrow - go back
|
||||
if finalize_hook:
|
||||
finalize_hook(model)
|
||||
break
|
||||
|
||||
if answer == "✓ Done" or answer is None:
|
||||
if finalize_hook:
|
||||
@@ -414,6 +537,20 @@ def _configure_pydantic_model(
|
||||
setattr(model, field_name, new_value)
|
||||
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":
|
||||
new_value = _input_bool(field_display, current_value)
|
||||
if new_value is not None:
|
||||
@@ -524,15 +661,13 @@ def _configure_providers(config: Config) -> None:
|
||||
while True:
|
||||
try:
|
||||
choices = get_provider_choices()
|
||||
answer = questionary.select(
|
||||
"Select provider:",
|
||||
choices=choices,
|
||||
qmark="→",
|
||||
).ask()
|
||||
answer = _select_with_back("Select provider:", choices)
|
||||
|
||||
if answer is None or answer == "← Back":
|
||||
if answer is _BACK_PRESSED or answer is None or answer == "← Back":
|
||||
break
|
||||
|
||||
# Type guard: answer is now guaranteed to be a string
|
||||
assert isinstance(answer, str)
|
||||
# Extract provider name from choice (remove " ✓" suffix if present)
|
||||
provider_name = answer.replace(" ✓", "")
|
||||
# Find the actual provider key from display names
|
||||
@@ -632,15 +767,13 @@ def _configure_channels(config: Config) -> None:
|
||||
|
||||
while True:
|
||||
try:
|
||||
answer = questionary.select(
|
||||
"Select channel:",
|
||||
choices=choices,
|
||||
qmark="→",
|
||||
).ask()
|
||||
answer = _select_with_back("Select channel:", choices)
|
||||
|
||||
if answer is None or answer == "← Back":
|
||||
if answer is _BACK_PRESSED or answer is None or answer == "← Back":
|
||||
break
|
||||
|
||||
# Type guard: answer is now guaranteed to be a string
|
||||
assert isinstance(answer, str)
|
||||
_configure_channel(config, answer)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Returning to main menu...[/dim]")
|
||||
|
||||
Reference in New Issue
Block a user