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 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-08 22:41:05 -04:00
1 parent bfae10b387
commit e0487b46e6
5 files changed
+230 -33

No files matched your search

+43 -29
View File
@@ -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)
+6 -2
View File
@@ -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,
)
+100
View File
@@ -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``).
+37 -1
View File
@@ -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,
+44 -1
View File
@@ -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"])