Render a numbered/keyed menu, return the selected key. ``options`` is the visible numbered list. ``extra_keys`` adds letter shortcuts (e.g. ``{"s": "Show all providers", "c": "Custom"}``) — these show up after the numbered rows and are accepted as input.
(
console: Console,
*,
title: str,
options: list[tuple[str, str, str]], # [(key, label, hint), ...]
default_key: str | None = None,
extra_keys: dict[str, str] | None = None,
prompt_label: str = "Choice",
invalid_label: str = "Invalid choice. Try again.",
)
| 267 | |
| 268 | |
| 269 | def select_from_options( |
| 270 | console: Console, |
| 271 | *, |
| 272 | title: str, |
| 273 | options: list[tuple[str, str, str]], # [(key, label, hint), ...] |
| 274 | default_key: str | None = None, |
| 275 | extra_keys: dict[str, str] | None = None, |
| 276 | prompt_label: str = "Choice", |
| 277 | invalid_label: str = "Invalid choice. Try again.", |
| 278 | ) -> str: |
| 279 | """Render a numbered/keyed menu, return the selected key. |
| 280 | |
| 281 | ``options`` is the visible numbered list. ``extra_keys`` adds letter |
| 282 | shortcuts (e.g. ``{"s": "Show all providers", "c": "Custom"}``) — these |
| 283 | show up after the numbered rows and are accepted as input. |
| 284 | """ |
| 285 | |
| 286 | # Titles come from i18n and may contain `[c]`-style brackets that Rich |
| 287 | # would otherwise interpret as markup tags. |
| 288 | console.print(f"[bold]{rich_escape(title)}[/bold]") |
| 289 | console.print() |
| 290 | table = Table.grid(padding=(0, 1)) |
| 291 | table.add_column(style="bright_cyan", justify="right") |
| 292 | table.add_column(style="bold") |
| 293 | table.add_column(style="dim") |
| 294 | |
| 295 | # Rich Table cells parse markup, so e.g. `[s]` would be eaten as a |
| 296 | # nonexistent tag. Wrap markers in Text so they render verbatim. |
| 297 | def _marker(text: str) -> Text: |
| 298 | return Text(text, style="bright_cyan", justify="right") |
| 299 | |
| 300 | for idx, (_key, label, hint) in enumerate(options, start=1): |
| 301 | table.add_row(_marker(f"[{idx}]"), label, hint or "") |
| 302 | if extra_keys: |
| 303 | for short, label in extra_keys.items(): |
| 304 | table.add_row(_marker(f"[{short}]"), label, "") |
| 305 | console.print(table) |
| 306 | console.print() |
| 307 | |
| 308 | valid_numbers = {str(i): options[i - 1][0] for i in range(1, len(options) + 1)} |
| 309 | valid_letters = {k.lower(): k.lower() for k in (extra_keys or {})} |
| 310 | default_input: str | None = None |
| 311 | if default_key is not None: |
| 312 | for idx, (key, _label, _hint) in enumerate(options, start=1): |
| 313 | if key == default_key: |
| 314 | default_input = str(idx) |
| 315 | break |
| 316 | if default_input is None and default_key.lower() in valid_letters: |
| 317 | default_input = default_key.lower() |
| 318 | |
| 319 | while True: |
| 320 | raw = typer.prompt(prompt_label, default=default_input or "") |
| 321 | choice = str(raw).strip().lower() |
| 322 | if choice in valid_numbers: |
| 323 | return valid_numbers[choice] |
| 324 | if choice in valid_letters: |
| 325 | return valid_letters[choice] |
| 326 | fail(console, invalid_label) |
no test coverage detected