From e0487b46e67ff9feb672306d2e51d48a1f84e71a Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Wed, 8 Jul 2026 22:41:05 -0400 Subject: [PATCH] Confirm quitting with a centered Cancel/Quit dialog Buttons read Cancel then Quit, with Quit highlighted so Enter commits it and Esc cancels. The question is orange, the selected button cyan, and the idle button gray, so the current choice is unmistakable. Co-Authored-By: Claude Opus 4.8 --- meshterm/ui/menu.py | 72 +++++++++++++++----------- meshterm/ui/tui/frame.py | 8 ++- meshterm/ui/tui/prompt.py | 100 +++++++++++++++++++++++++++++++++++++ meshterm/ui/tui/session.py | 38 +++++++++++++- tests/test_tui.py | 45 ++++++++++++++++- 5 files changed, 230 insertions(+), 33 deletions(-) diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 3496405..a1a2b6e 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -9,6 +9,7 @@ prompts layer as dialogs, and its output appears in a bounded, scrollable result from __future__ import annotations +import asyncio import logging import os import sys @@ -26,7 +27,7 @@ from ..persistence.logging import get_logger from ..tools import all_tools from .surface import TuiUi from .theme import make_console -from .tui import Choice, Separator, TuiSession +from .tui import CANCEL, Choice, SelectScreen, Separator, TuiSession from .tui.emoji_width import calibrate as calibrate_emoji_width @@ -170,6 +171,7 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None: session: The running TUI session. """ last_selection: str | None = None + loop = asyncio.get_running_loop() while True: items: list = [] current_category: str | None = None @@ -181,35 +183,47 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None: items.append(Separator(" ")) items.append(Choice(title="quit", value="__quit__")) - # Re-highlight the tool the user just backed out of, so returning to the menu - # lands the cursor where they left rather than at the top. - selection = await session.select( - "What would you like to do?", items, default=last_selection + # Drive the menu list ourselves (rather than via session.select) so it stays on the + # stack while the quit dialog floats over it: the confirm is drawn as a centered box + # on top of the still-visible menu, not as a screen that replaces it. Re-highlight the + # tool the user just backed out of so returning lands the cursor where they left. + menu = SelectScreen( + "What would you like to do?", + items, + default=last_selection, + footer_hint="↑↓ move · type to filter · Enter select · Esc quit", ) - # Both picking "quit" and pressing Esc at the top level ask to leave; confirm on a - # small dialog first so a stray key doesn't drop the user out of the session. Enter - # takes the highlighted default (Quit) and leaves; Esc — or choosing "Stay" — returns - # to the menu (select maps a cancel to None). - if selection in (None, "__quit__"): - leave = await session.select( - "Leave MeshTerm?", - [ - Choice(title="Quit and disconnect", value="quit"), - Choice(title="Stay", value="stay"), - ], - default="quit", - wrap=False, - filterable=False, - footer_hint="↑↓ move · Enter quit · Esc stay", - ) - if leave == "quit": - return - # Keep the cursor on "quit" when that is what they chose, so a follow-up - # attempt lands where they expect; a stray Esc leaves it where it was. - if selection == "__quit__": - last_selection = "__quit__" - continue - last_selection = selection + menu.future = loop.create_future() + session.push(menu) + try: + selection = await menu.future + if selection is CANCEL: # Esc at the top level + selection = None + # Both picking "quit" and pressing Esc ask to leave; confirm on a dialog floating + # over the (still-pushed) menu so a stray key doesn't drop the user out. Cancel + # (Esc) sits left of Quit (Enter); Quit starts highlighted so Enter commits it. + if selection in (None, "__quit__"): + leave = await session.button_dialog( + "Are you sure you want to quit?", + [("Cancel", False), ("Quit", True)], + title="Quit MeshTerm", + default=1, + footer_hint="Esc cancel · Enter quit", + prompt_style="warn", + button_style="brand", + button_idle_style="muted", + border_style="warn", + ) + if leave: + return + # Keep the cursor on "quit" when that is what they chose, so a follow-up + # attempt lands where they expect; a stray Esc leaves it where it was. + if selection == "__quit__": + last_selection = "__quit__" + continue + last_selection = selection + finally: + session.pop(menu) await _run_selection(ctx, selection) diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 4ca7edb..dc05a7f 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -246,7 +246,11 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: Returns: An ANSI string sized to the dialog's content (never larger than the terminal). """ - max_w = min(cols - 6, 100) + # Most dialogs stretch to a generous cap; a screen may instead request a natural width + # (a short confirm sized to its content), still bounded to the terminal. + cap = min(cols - 6, 100) + natural = getattr(screen, "dialog_width", None) + max_w = cap if natural is None else max(24, min(cap, natural)) max_h = max(3, rows - 6) body_lines = screen.render_body(max_w - 4) viewport = min(max_h, max(1, len(body_lines))) @@ -260,7 +264,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str: body, title=f"[accent]{screen.title}[/accent]" if screen.title else None, subtitle=subtitle, - border_style="accent", + border_style=getattr(screen, "border_style", "accent"), padding=(0, 1), width=max_w, ) diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index 8f26d8d..b31f0e0 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import Callable, Optional +from rich.cells import cell_len from rich.console import Group, RenderableType from rich.text import Text @@ -216,6 +217,105 @@ class ConfirmScreen(Screen): super().handle("escape") +class ButtonDialog(Screen): + """A centered dialog: a prompt above a row of side-by-side buttons. + + A reusable choose-one prompt. The buttons sit in a row; ←/→ (or Tab) move the highlight, + Enter commits the highlighted button, and Esc cancels. Optional single-key shortcuts + commit a button instantly (e.g. ``y``/``n``, self-hinted by ``Yes``/``No`` labels). The + highlight and border colours are parametrised so a caller can theme it (a destructive + action in red, say). Resolves with the chosen button's value, or CANCEL on Esc. + """ + + def __init__( + self, + prompt: str, + buttons: list[tuple[str, object]], + *, + title: str = "", + default: int = 0, + keys: Optional[dict[str, object]] = None, + footer_hint: str = "←→ choose · Enter select · Esc cancel", + prompt_style: str = "", + button_style: str = "reverse brand", + button_idle_style: str = "muted", + border_style: str = "accent", + ) -> None: + """Build a button dialog. + + Args: + prompt: The question shown above the buttons. + buttons: ``(label, value)`` pairs laid out left to right. + title: Optional dialog heading. + default: Index of the initially highlighted button. + keys: Optional map of a lowercase shortcut character to the value it commits + immediately (bypassing the highlight), e.g. ``{"y": True, "n": False}``. + footer_hint: Footer key hint (hints Enter and Esc, as the other dialogs do). + prompt_style: Rich style for the question (e.g. ``"warn"`` for a cautionary + action); empty for the default foreground. + button_style: Rich style for the highlighted button. + button_idle_style: Rich style for the un-highlighted buttons. + border_style: Rich style for the dialog border (read by the frame compositor). + """ + super().__init__() + self.title = title + self.footer_hint = footer_hint + self.border_style = border_style + self._prompt = prompt + self._buttons = buttons + self._index = default if 0 <= default < len(buttons) else 0 + self._keys = {k.lower(): v for k, v in (keys or {}).items()} + self._prompt_style = prompt_style + self._button_style = button_style + self._button_idle_style = button_idle_style + + @property + def _button_row_width(self) -> int: + """Display width of the button row (`` Label `` cells joined by 4-space gaps).""" + cells = sum(cell_len(label) + 4 for label, _ in self._buttons) + gaps = 4 * max(0, len(self._buttons) - 1) + return cells + gaps + + @property + def dialog_width(self) -> int: + """Natural outer width so the frame sizes the box to its content, not the terminal. + + The widest of the prompt, button row, title, and footer hint, plus a comfortable + margin and the border — so a short confirm reads as a tidy box rather than a banner + stretched across the screen. The compositor still caps this to the terminal. + """ + inner = max( + cell_len(self._prompt), + self._button_row_width, + cell_len(self.title), + cell_len(self.footer_hint), + ) + return inner + 12 # panel padding + border, plus horizontal breathing room + + def render_body(self, width: int) -> list[str]: + """Render the prompt centered above a centered row of buttons.""" + row = Text(justify="center") + for i, (label, _value) in enumerate(self._buttons): + if i: + row.append(" ") + style = self._button_style if i == self._index else self._button_idle_style + row.append(f" {label} ", style=style) + prompt = Text(self._prompt, style=self._prompt_style, justify="center") + return render_lines(Group(prompt, Text(""), row), width) + + def handle(self, action: str, data: str = "") -> None: + """Move the highlight, commit on Enter or a shortcut key, or cancel on Esc.""" + if action in ("left", "right", "tab") and self._buttons: + step = -1 if action == "left" else 1 + self._index = (self._index + step) % len(self._buttons) + elif action == "text" and data.lower() in self._keys: + self.resolve(self._keys[data.lower()]) + elif action == "enter" and self._buttons: + self.resolve(self._buttons[self._index][1]) + elif action == "escape": + super().handle("escape") + + class AutocompleteScreen(Screen): """A free-text field with a live suggestion list (``questionary.autocomplete``). diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 3a5d27b..f90d20a 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -26,7 +26,7 @@ from rich.text import Text from . import frame from .progress import TuiProgress -from .prompt import AutocompleteScreen, ConfirmScreen, TextScreen, Validator +from .prompt import AutocompleteScreen, ButtonDialog, ConfirmScreen, TextScreen, Validator from .screen import CANCEL, BusyScreen, Screen, ScrollScreen from .select import Choice, ReorderScreen, SelectScreen, Separator @@ -329,6 +329,42 @@ class TuiSession: result = await self.run_screen(ConfirmScreen(title, default=default)) return None if result is CANCEL else result + async def button_dialog( + self, + prompt: str, + buttons: list[tuple[str, Any]], + *, + title: str = "", + default: int = 0, + keys: Optional[dict[str, Any]] = None, + footer_hint: str = "←→ choose · Enter select · Esc cancel", + prompt_style: str = "", + button_style: str = "reverse brand", + button_idle_style: str = "muted", + border_style: str = "accent", + ) -> Any: + """Show a centered button dialog; return the chosen value or ``None`` if cancelled. + + A reusable prompt-above-buttons dialog (see :class:`~meshterm.ui.tui.prompt. + ButtonDialog`): the colours, prompt, buttons, and single-key shortcuts are all + parametrised, so a caller can theme it (e.g. a destructive action in red) or wire + instant y/n keys. ``keys`` maps a shortcut character to the value it commits. + """ + screen = ButtonDialog( + prompt, + buttons, + title=title, + default=default, + keys=keys, + footer_hint=footer_hint, + prompt_style=prompt_style, + button_style=button_style, + button_idle_style=button_idle_style, + border_style=border_style, + ) + result = await self.run_screen(screen) + return None if result is CANCEL else result + async def autocomplete( self, title: str, diff --git a/tests/test_tui.py b/tests/test_tui.py index 5352a80..1c5ed60 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -17,7 +17,12 @@ from rich.text import Text from meshterm.ui.tui import frame from meshterm.ui.tui.progress import ProgressScreen -from meshterm.ui.tui.prompt import AutocompleteScreen, ConfirmScreen, TextScreen +from meshterm.ui.tui.prompt import ( + AutocompleteScreen, + ButtonDialog, + ConfirmScreen, + TextScreen, +) from meshterm.ui.tui.render import render_lines, render_to_ansi from meshterm.ui.tui.screen import CANCEL, Screen, ScrollScreen from meshterm.ui.tui.select import Choice, SelectScreen, Separator @@ -426,6 +431,44 @@ def test_confirm_toggle_and_default() -> None: assert _run(screen, "enter") is False +def test_button_dialog_enter_commits_highlighted() -> None: + """Enter returns the highlighted button's value; the default sets the highlight.""" + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0) + assert _run(screen, "enter") is True + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=1) + assert _run(screen, "enter") is False + + +def test_button_dialog_arrows_move_highlight() -> None: + """←/→ (and Tab) move the highlight between the buttons, wrapping at the ends.""" + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0) + screen.handle("right") # → No + assert _run(screen, "enter") is False + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)], default=0) + screen.handle("left") # wraps → No + assert _run(screen, "enter") is False + + +def test_button_dialog_shortcut_keys_commit_instantly() -> None: + """A mapped shortcut key commits its value straight away, bypassing the highlight.""" + screen = ButtonDialog( + "quit?", [("Yes", True), ("No", False)], default=1, keys={"y": True, "n": False} + ) + assert _run(screen, "text", "Y") is True # case-insensitive, ignores the No default + screen = ButtonDialog( + "quit?", [("Yes", True), ("No", False)], default=0, keys={"y": True, "n": False} + ) + assert _run(screen, "text", "n") is False + + +def test_button_dialog_escape_cancels() -> None: + """Esc resolves with CANCEL so the caller can treat it as 'stay'.""" + from meshterm.ui.tui.screen import CANCEL + + screen = ButtonDialog("quit?", [("Yes", True), ("No", False)]) + assert _run(screen, "escape") is CANCEL + + def test_autocomplete_suggests_and_tab_completes() -> None: """Suggestions match case-insensitively; Tab fills the highlighted one; Enter commits.""" screen = AutocompleteScreen("target?", ["Alice", "Bob", "alfred"])