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()
|
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]")
|
||||||
|
|||||||
Reference in New Issue
Block a user