Files
MeshTerm/meshterm/ui/surface.py
T
Jean-Pierre MartineauandClaude Opus 5 e3ae668f4a Give the person at the prompt back what a redirect never wanted
Five small things the seam left standing, each one a rule applied a step too
far or a question asked of the wrong flag.

ack and a tool's closing message go to stderr instead of being dropped. The
reason they were dropped was sound as far as it went — an acknowledgement is not
the answer, and `meshterm contacts > f` must catch only the answer — but stdout
is not the only stream. Someone watching a five-minute `config restore` gets the
setting-by-setting ✓ back, and the redirect still catches nothing extra.

tx-optimize asked --json whether it could prompt. That is a question about
output format, and it answered this one wrong in both directions: with the flag
a piped run failed cleanly, without it a scheduled run on a terminal sat waiting
for a password nobody was going to type. It now asks stdin — and asks it
properly on Windows, where NUL is a character device that isatty() calls a
terminal and getpass then reads the console directly and blocks forever. A
missing password is a usage error, since nothing was transmitted.

courier send on an id nobody has raised a device failure, which told a scheduled
sender to retry a typo. It is a bad argument: the outbox is a local file.

--mock alongside --port/--ble/--tcp used to let --mock win silently, so a
scheduled `--port COM7 --mock info` reported on a simulator while reading as
though it had reached the radio.

And stderr stops folding at 80 columns off a terminal, which is what made
`grep 'not already connected' errors.log` come back empty — the sentence was
there, with a newline through the middle of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 03:56:35 -04:00

860 lines
31 KiB
Python

"""The UI surface: one small API tools use for input and output, in either front-end.
Tools never touch ``questionary`` or a raw console anymore — they talk to ``ctx.ui``. Two
implementations back it:
* :class:`PlainUi` (scripted CLI): prints immediately and uses a Rich progress bar, so CLI
behavior is byte-for-byte what it was before the TUI existed.
* :class:`TuiUi` (interactive menu): collects a tool's output and presents it in a bounded,
scrollable result window — or, when the whole result is just a line or two of text
("✓ flood advertisement sent"), as a small centered popup with an OK button instead of
a full window (see :func:`_collapse_to_message`) — and routes every prompt/progress
through the full-screen :class:`~meshterm.ui.tui.session.TuiSession`.
The interactive prompt methods are only ever reached from a tool's ``prompt_params`` (menu
only), so :class:`PlainUi` leaves them unsupported.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from typing import Any
from rich.console import Console, Group, RenderableType
from rich.text import Text
from .tui.session import TuiSession
#: A validator returns ``True`` when input is acceptable, or an error message to show.
Validator = Callable[[str], "bool | str"]
#: Buffered output no taller than this many lines qualifies for the message dialog —
#: the OK popup :meth:`TuiUi.present` shows instead of a full result window. Kept small:
#: a dialog is an acknowledgement, not a reading surface.
_DIALOG_MAX_LINES = 3
#: ... and no line may be wider than this many cells, so the popup stays a tidy box on a
#: typical terminal and never has to wrap. Anything wider (a long path, a verbose error)
#: falls back to the scrollable result window, which wraps properly.
_DIALOG_MAX_CELLS = 76
def _dialog_max_cells() -> int:
"""The popup-qualifying width for the active platform.
The regular platform keeps the long-standing 76. On the PicoCalc the dialog frame
itself is capped at ``cols - 6 = 47`` outer (43 inner), so a line qualifying at 76
would wrap inside the box it was promoted into — the gate shrinks to match.
"""
from ..platforms import get_platform
return _DIALOG_MAX_CELLS if get_platform().frame_border else 43
def _collapse_to_message(buffered: list[RenderableType]) -> Text | None:
"""Collapse small, text-only buffered output into one dialog message.
This is the gate for :meth:`TuiUi.present`'s popup upgrade: output qualifies only
when everything buffered is plain note text (:class:`Text` — a table or panel from
``show()`` disqualifies the lot) totalling at most :data:`_DIALOG_MAX_LINES` lines,
none wider than :data:`_DIALOG_MAX_CELLS` cells. Styling (the ``[ok]``/``[err]``
markup on outcome notes) is preserved in the joined message.
Args:
buffered: The renderables collected since the last present.
Returns:
The lines joined into one :class:`Text`, or ``None`` when the output belongs in
the scrollable result window instead.
"""
if not buffered or not all(isinstance(item, Text) for item in buffered):
return None
lines: list[Text] = []
for item in buffered:
lines.extend(item.split("\n") or [item])
if len(lines) > _DIALOG_MAX_LINES:
return None
if any(line.cell_len > _dialog_max_cells() for line in lines):
return None
return Text("\n").join(lines)
class Ui:
"""Abstract UI surface. See :class:`PlainUi` and :class:`TuiUi` for the two backends."""
def show(self, *renderables: RenderableType) -> None:
"""Display one or more Rich renderables (tables, panels, text)."""
raise NotImplementedError
def note(self, markup: str) -> None:
"""Display a short line of Rich-markup text."""
raise NotImplementedError
def ack(self, markup: str) -> None:
"""Acknowledge that an action was carried out — in the menu only.
The distinction from :meth:`note` is who the line is for. A note is *output*: the
answer the caller asked for. An acknowledgement is reassurance that a thing
happened — "✓ device clock set", "✓ channel 2 = #general" — which a person watching
a screen needs and a script does not, because the exit status already said it and
the line would only be something to filter out of the real output.
Both surfaces show them; what differs is *where*. The menu prints them in its
result window. The CLI prints them on **stderr** (see :meth:`PlainUi.ack`), which
keeps the promise the dropping was made to keep — ``meshterm contacts > f`` still
catches only the answer — while giving the person at the prompt back the ✓ and the
count they were losing to a rule written for a redirect they were not doing.
"""
raise NotImplementedError
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Show a renderable immediately in a dismissable window (prints in CLI mode)."""
raise NotImplementedError
async def present(self, *, title: str = "") -> None:
"""Flush any buffered output to the user (a no-op when output is immediate)."""
def discard(self) -> None:
"""Drop any buffered-but-unshown output (a no-op when output is immediate)."""
def progress(self, title: str = "Working"): # noqa: ANN201 - context manager, varies by backend
"""Return a progress context manager exposing ``add_task``/``advance``/``update``."""
raise NotImplementedError
def busy_overlay(self, message: str = "", *, title: str = ""): # noqa: ANN201 - async CM, varies by backend
"""Return an async context manager that floats a "working" skeleton while its block runs.
In the interactive menu this shows the top-most skeleton card (see
:meth:`~meshterm.ui.tui.session.TuiSession.busy_overlay`), used to cover the lag of a
Bluetooth operation that would otherwise leave the screen blank. In scripted CLI mode
it does nothing (there is no full-screen surface to float over).
"""
raise NotImplementedError
async def select(
self,
title: str,
items: list,
*,
prompt: str = "",
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
) -> Any:
"""Prompt the user to choose one item; return its value or ``None`` if cancelled.
``prompt`` draws an instruction inside the popup, above the list. Pass
``filterable=False`` for a short, fixed list so a stray key can't narrow (and
resize) it. ``delete_hint`` (with rows marked deletable) enables the Delete
key's remove flow — see :meth:`~meshterm.ui.tui.session.TuiSession.select`.
"""
raise NotImplementedError
async def select_startup(
self,
title: str,
items: list,
*,
default: Any = None,
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""Choose one item on a chromeless startup splash; ``None`` if skipped."""
raise NotImplementedError
async def confirm_startup(
self,
prompt: str | Text,
*,
title: str = "",
confirm_label: str = "Remove",
banner: Any = None,
footnote: str | None = None,
backdrop_items: list | None = None,
backdrop_default: Any = None,
) -> bool:
"""Confirm a destructive action on the startup splash; ``True`` only if committed.
``backdrop_items`` (the picker's rows) floats the confirm over a redrawn device list;
``backdrop_default`` pre-highlights the row it acts on.
"""
raise NotImplementedError
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> None:
"""Show a message on a chromeless startup splash until the user dismisses it."""
raise NotImplementedError
async def busy_startup(
self,
message: str,
coro: Any,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""Await ``coro`` while showing a spinner on the startup splash; return its result."""
raise NotImplementedError
async def prompt_pin_startup(
self,
device_name: str,
*,
error: str = "",
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Ask for a Bluetooth companion's pairing PIN on the splash; ``None`` if cancelled."""
raise NotImplementedError
async def prompt_text_startup(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Ask for a line of text on a chromeless startup splash; ``None`` if cancelled."""
raise NotImplementedError
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Let the user rearrange rows with the arrows; return the new order of row indices."""
raise NotImplementedError
async def text(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> str | None:
"""Prompt for a line of text; return it or ``None`` if cancelled.
Keep ``title`` short (it heads the popup's border) and put the question/instruction
in ``prompt`` (drawn above the field), so a text popup reads like the button dialogs.
``floating`` forces the prompt to draw as a centered popup even with nothing beneath
it (a mid-flow modal such as a remote-admin password) instead of filling the frame;
surfaces without a screen stack ignore it.
"""
raise NotImplementedError
async def confirm(self, title: str, *, default: bool = True) -> bool | None:
"""Prompt yes/no; return the answer or ``None`` if cancelled."""
raise NotImplementedError
async def dialog(
self,
prompt: str,
buttons: list[tuple[str, Any]],
*,
title: str = "",
default: int = 0,
keys: dict[str, Any] | None = None,
danger: bool = False,
destructive: bool = False,
) -> Any:
"""Show a centered button dialog; return the chosen value or ``None`` on Esc.
The general choose-one popup (a prompt above a row of buttons). Two escalating
cautionary tiers theme the prompt and border: ``danger`` (amber) for a disruptive
choice — discarding edits, a reboot — and ``destructive`` (the reserved error red)
for irreversible data loss, so a delete confirm reads red like its typed-confirm
sibling. Buttons follow the platform-dialog convention: the safe way out sits on
the left and the committing action on the right, which is also the sensible
``default`` so Enter commits it while Esc always backs out.
"""
raise NotImplementedError
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
"""Gate a destructive action behind typing ``word``; return whether it was typed."""
raise NotImplementedError
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
) -> str | None:
"""Prompt for free text with suggestions; return it or ``None`` if cancelled."""
raise NotImplementedError
async def path(self, title: str, *, prompt: str = "", default: str = "") -> str | None:
"""Prompt for a filesystem path; return it or ``None`` if cancelled."""
raise NotImplementedError
class PlainUi(Ui):
"""CLI surface: print straight to the scripted console; interactive prompts are unsupported.
The console it prints on emits no colour, wraps nothing, and trims trailing whitespace
(:func:`meshterm.ui.script.console`), and everything a tool hands over passes through
:func:`~meshterm.ui.script.flatten` on the way out, so no border, box or hoisted title
reaches stdout. That fold is the net, not the design: a surface a script is meant to
read is *written* in the CLI's own vocabulary — see :mod:`meshterm.ui.script`.
"""
def __init__(self, console: Console) -> None:
"""Bind the surface to a Rich console.
Args:
console: The console tool output is printed to.
"""
self.console = console
def show(self, *renderables: RenderableType) -> None:
"""Print each renderable immediately, unframed (see :func:`~meshterm.ui.script.flatten`)."""
from . import script
for renderable in renderables:
for item in script.flatten(renderable):
self.console.print(item)
def note(self, markup: str) -> None:
"""Print a markup line to the console immediately, as plain text.
The parameter is markup — that is the shared surface's contract, and forty-odd
callers write it — but the scripted console does not interpret markup: a node
broadcasts its own name, and Rich would read ``[...]`` in one as a style tag. So
the tags are resolved *here* rather than by the console, and what reaches stdout
is the text they were wrapping. Printing them raw put ``[muted]`` and ``[/muted]``
around the private key that ``config export-key > key.hex`` is supposed to be the
whole content of.
Malformed markup — which is what a *name* holding a bracket looks like — is not an
error to report but a string to print, so it falls through verbatim.
"""
from rich.errors import MarkupError
from rich.markup import render
try:
self.console.print(render(markup).plain)
except MarkupError:
self.console.print(markup)
def ack(self, markup: str) -> None:
"""Print it on stderr, where everything *about* the run goes.
These used to be dropped, and the reason was sound as far as it went: an
acknowledgement is not the answer, and a caller redirecting stdout must catch only
the answer. But stdout is not the only stream — putting them on stderr honours that
rule exactly, and a person watching a five-minute ``config restore`` gets the
setting-by-setting ✓ they had no way to see.
The markup is resolved here rather than by the console, as :meth:`note` explains,
and a *name* holding a square bracket is not an error to report but a string to
print — so malformed markup falls through verbatim.
"""
from rich.errors import MarkupError
from rich.markup import render
from . import script
try:
line: Text | str = render(markup)
except MarkupError:
line = markup
script.stderr_console().print(line, highlight=False)
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Print the renderable immediately, unframed (there is no windowing in CLI mode)."""
from . import script
for item in script.flatten(renderable):
self.console.print(item)
def progress(self, title: str = "Working"): # noqa: ANN201
"""Return a progress bar drawn on stderr, so it never lands in piped output.
A progress bar is a courtesy to someone watching a long command run, and noise in
anything reading the command's output. stderr is where it belongs: visible in a
terminal, absent from ``meshterm contacts > contacts.txt``. Rich also draws
nothing at all when stderr is not a terminal, so a fully redirected run is silent.
"""
from . import script
from .widgets import make_progress
console = script.stderr_console()
progress = make_progress(console)
progress.disable = not console.is_terminal
return progress
@asynccontextmanager
async def busy_overlay(self, message: str = "", *, title: str = "") -> AsyncIterator[None]:
"""Do nothing: the scripted CLI has no full-screen surface to float a skeleton over."""
yield
def _no_prompt(self) -> RuntimeError:
"""Build the error raised if a rich prompt is reached on the non-interactive path."""
return RuntimeError("interactive prompts are only available in the menu")
async def select(
self,
title: str,
items: list,
*,
prompt: str = "",
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def select_startup(
self,
title: str,
items: list,
*,
default: Any = None,
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def confirm_startup(
self,
prompt: str | Text,
*,
title: str = "",
confirm_label: str = "Remove",
banner: Any = None,
footnote: str | None = None,
backdrop_items: list | None = None,
backdrop_default: Any = None,
) -> bool:
"""Unsupported in scripted CLI mode — the picker splash is interactive-only."""
raise self._no_prompt()
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def busy_startup(
self,
message: str,
coro: Any,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""No splash in scripted CLI mode; just await the task and return its result."""
return await coro
async def prompt_pin_startup(
self,
device_name: str,
*,
error: str = "",
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Unsupported in scripted CLI mode — a PIN must be supplied non-interactively.
The scripted path can't pop a dialog, so a PIN-protected device is handled by the
clean ``DeviceAuthenticationError`` message (pass ``--ble-pin``) rather than a prompt.
"""
raise self._no_prompt()
async def prompt_text_startup(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Unsupported in scripted CLI mode — a network endpoint comes from ``--tcp`` instead."""
raise self._no_prompt()
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def text(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
password: bool = False,
floating: bool = False,
) -> str | None:
"""Prompt on the terminal (line editor / getpass), re-asking until valid.
A few tools (e.g. a remote-admin password) can legitimately prompt from a scripted
run when no flag was supplied, so this stays functional on the CLI. An in-body
``prompt`` (used by the interactive popups) is printed once as a lead-in line here.
``floating`` is a full-screen-popup nicety with no meaning on the plain terminal, so
it is accepted and ignored.
Returns:
The entered string, or ``None`` on EOF / interrupt.
"""
import getpass
if prompt:
self.console.print(prompt)
head = f"{title} " if not default else f"{title} [{default}] "
prompt = head
while True:
try:
raw = getpass.getpass(prompt) if password else input(prompt)
except (EOFError, KeyboardInterrupt):
return None
value = raw if raw != "" else default
if validate is not None:
result = validate(value)
if result is not True:
self.console.print(f"[err]{result}[/err]")
continue
return value
async def confirm(self, title: str, *, default: bool = True) -> bool | None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def dialog(
self,
prompt: str,
buttons: list[tuple[str, Any]],
*,
title: str = "",
default: int = 0,
keys: dict[str, Any] | None = None,
danger: bool = False,
destructive: bool = False,
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
"""Unsupported in scripted CLI mode (destructive CLI commands gate on ``--yes``)."""
raise self._no_prompt()
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
) -> str | None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
async def path(self, title: str, *, prompt: str = "", default: str = "") -> str | None:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
class TuiUi(Ui):
"""Interactive surface: buffer output for a result window; route prompts to the session."""
def __init__(self, session: TuiSession) -> None:
"""Bind the surface to a running session.
Args:
session: The full-screen session that renders prompts and windows.
"""
self.session = session
self._buffer: list[RenderableType] = []
# --- output --------------------------------------------------------------
def show(self, *renderables: RenderableType) -> None:
"""Collect renderables for the next result window."""
self._buffer.extend(renderables)
def note(self, markup: str) -> None:
"""Collect a markup line for the next result window."""
self._buffer.append(Text.from_markup(markup))
def ack(self, markup: str) -> None:
"""Collect an acknowledgement — on this surface, exactly a note."""
self.note(markup)
async def present(self, *, title: str = "") -> None:
"""Show everything buffered since the last present; window or popup to fit.
A short, text-only outcome (a line or two of notes — "✓ flood advertisement
sent") floats as a centered OK dialog over the current screen, so a one-line
result never commandeers the whole frame; anything bigger, or anything holding
a table/panel, opens the bounded scrollable result window as before (see
:func:`_collapse_to_message` for the exact gate). Clears the buffer afterward.
Does nothing if nothing was buffered (e.g. a tool that only produced a file
artifact and an empty message).
Args:
title: Heading for the result window or popup.
"""
if not self._buffer:
return
buffered = self._buffer
self._buffer = []
message = _collapse_to_message(buffered)
if message is not None:
await self.session.message_dialog(message, title=title)
return
body = buffered[0] if len(buffered) == 1 else Group(*buffered)
await self.session.scroll(body, title=title)
def discard(self) -> None:
"""Drop any buffered output without showing it."""
self._buffer = []
async def view(
self, renderable: RenderableType, *, title: str = "", footer_hint: str = ""
) -> None:
"""Show a renderable immediately in a dismissable scroll window."""
await self.session.scroll(renderable, title=title, footer_hint=footer_hint)
def progress(self, title: str = "Working"): # noqa: ANN201
"""Return a progress dialog context manager for the session."""
return self.session.progress(title)
def busy_overlay(self, message: str = "", *, title: str = ""): # noqa: ANN201
"""Float the session's top-most skeleton card while the wrapped block runs."""
return self.session.busy_overlay(message, title=title)
# --- input ---------------------------------------------------------------
async def select(
self,
title: str,
items: list,
*,
prompt: str = "",
default: Any = None,
filterable: bool = True,
delete_hint: str = "",
) -> Any:
"""Delegate to the session's select screen."""
return await self.session.select(
title,
items,
prompt=prompt,
default=default,
filterable=filterable,
delete_hint=delete_hint,
)
async def select_startup(
self,
title: str,
items: list,
*,
default: Any = None,
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""Delegate to the session's chromeless startup select splash."""
return await self.session.select_startup(
title, items, default=default, banner=banner, footnote=footnote
)
async def confirm_startup(
self,
prompt: str | Text,
*,
title: str = "",
confirm_label: str = "Remove",
banner: Any = None,
footnote: str | None = None,
backdrop_items: list | None = None,
backdrop_default: Any = None,
) -> bool:
"""Delegate to the session's startup confirm dialog (floated over the picker)."""
return await self.session.confirm_startup(
prompt,
title=title,
confirm_label=confirm_label,
banner=banner,
footnote=footnote,
backdrop_items=backdrop_items,
backdrop_default=backdrop_default,
)
async def notify_startup(
self,
renderable: RenderableType,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> None:
"""Delegate to the session's chromeless startup message splash."""
await self.session.notify_startup(renderable, title=title, banner=banner, footnote=footnote)
async def busy_startup(
self,
message: str,
coro: Any,
*,
title: str = "",
banner: Any = None,
footnote: str | None = None,
) -> Any:
"""Delegate to the session's animated-spinner startup splash."""
return await self.session.busy_startup(
message, coro, title=title, banner=banner, footnote=footnote
)
async def prompt_pin_startup(
self,
device_name: str,
*,
error: str = "",
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Delegate to the session's startup PIN dialog."""
return await self.session.prompt_pin_startup(
device_name, error=error, help_text=help_text, banner=banner, footnote=footnote
)
async def prompt_text_startup(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
banner: Any = None,
footnote: str | None = None,
) -> str | None:
"""Delegate to the session's chromeless startup text dialog."""
return await self.session.prompt_text_startup(
title,
prompt=prompt,
default=default,
validate=validate,
help_text=help_text,
banner=banner,
footnote=footnote,
)
async def reorder(self, title: str, labels: list[str]) -> list[int]:
"""Delegate to the session's reorder screen."""
return await self.session.reorder(title, labels)
async def text(
self,
title: str,
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
help_text: str = "",
password: bool = False,
byte_limit: int | None = None,
floating: bool = False,
) -> str | None:
"""Delegate to the session's text screen."""
return await self.session.text(
title,
prompt=prompt,
default=default,
validate=validate,
help_text=help_text,
password=password,
byte_limit=byte_limit,
floating=floating,
)
async def confirm(self, title: str, *, default: bool = True) -> bool | None:
"""Delegate to the session's confirm screen."""
return await self.session.confirm(title, default=default)
async def dialog(
self,
prompt: str,
buttons: list[tuple[str, Any]],
*,
title: str = "",
default: int = 0,
keys: dict[str, Any] | None = None,
danger: bool = False,
destructive: bool = False,
) -> Any:
"""Delegate to the session's button dialog, in the caution tier the caller asked for.
``danger`` themes the frame cautionary; ``destructive`` uses the reserved error
red, and is only for irreversible data loss.
"""
tier = "err" if destructive else "warn" if danger else ""
return await self.session.button_dialog(
prompt,
buttons,
title=title,
default=default,
keys=keys,
prompt_style=tier,
border_style=tier or "accent",
)
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
"""Delegate to the session's typed-confirmation dialog."""
return await self.session.typed_confirm(warning, word, title=title)
async def autocomplete(
self,
title: str,
choices: list[str],
*,
prompt: str = "",
default: str = "",
validate: Validator | None = None,
) -> str | None:
"""Delegate to the session's autocomplete screen."""
return await self.session.autocomplete(
title, choices, prompt=prompt, default=default, validate=validate
)
async def path(self, title: str, *, prompt: str = "", default: str = "") -> str | None:
"""Prompt for a path as free text (with the current value prefilled)."""
return await self.session.text(
title, prompt=prompt, default=default, help_text="filesystem path"
)