core/prompting: let a prompt say what it asks and when silence answers

`PromptWait` only had a short title and the reason line behind
`include_reason`, so a host that keeps the reason out had a bare title to
show — and a question, whose text *is* what is asked, was cut to the title's
few words. It now carries `body` (the question itself), `facts` (the object:
a program or a file name, never an argument) and `expires_in` (when the
default answer takes over). `ask` and `choose` put their question in the body
and a choice names its options; a secret says in its docstring why its prompt
is announced but never answered.
This commit is contained in:
2026-10-24 04:21:14 +08:00
parent caecf62f47
commit 5f7a70bf84
+41 -10
View File
@@ -125,17 +125,27 @@ class PromptWait:
``kind`` names the prompt family (``confirm``, ``ask``, ``secret``, ``kind`` names the prompt family (``confirm``, ``ask``, ``secret``,
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can ``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
tailor what it shows and which answers it offers; ``title`` is short and tailor what it shows and which answers it offers; ``title`` is short and
safe to surface anywhere, while ``detail`` carries one optional line the safe to surface anywhere. ``body`` is the prompt itself — a question, which
caller has already redacted. ``choices`` are the answers this particular the host shows as it stands — while ``detail`` carries one optional reason
prompt accepts, as ``(key, label)`` pairs — a host that offers them sends line the caller has already redacted: it can name a command or a URL, so a
the key back, so the label can stay human and the key stay the token the host shows it only when the human allowed that. ``facts`` are short items
terminal read would accept. Empty means the host decides. naming the *object* of the prompt (a program or a file name, never an
argument): they travel unconditionally, so a host that hides the reason
still says what the prompt is about. ``expires_in`` is how long the prompt
waits before taking its default answer, which is usually "deny" — a host
says so, because not answering is then an answer. ``choices`` are the
answers this particular prompt accepts, as ``(key, label)`` pairs — a host
that offers them sends the key back, so the label can stay human and the key
stay the token the terminal read would accept. Empty means the host decides.
""" """
kind: str kind: str
title: str title: str
detail: str = "" detail: str = ""
choices: tuple[tuple[str, str], ...] = () choices: tuple[tuple[str, str], ...] = ()
body: str = ""
facts: tuple[str, ...] = ()
expires_in: float | None = None
@runtime_checkable @runtime_checkable
@@ -185,11 +195,17 @@ def prompt_wait(
title: str, title: str,
detail: str = "", detail: str = "",
choices: tuple[tuple[str, str], ...] = (), choices: tuple[tuple[str, str], ...] = (),
*,
body: str = "",
facts: tuple[str, ...] = (),
expires_in: float | None = None,
) -> Generator[PromptAnswerSource | None]: ) -> Generator[PromptAnswerSource | None]:
"""Announce a blocking prompt to the host; yields its answer source (or None). """Announce a blocking prompt to the host; yields its answer source (or None).
``choices`` says which answers this prompt accepts (see :class:`PromptWait`); ``choices`` says which answers this prompt accepts, ``body`` what it asks,
a host that shows them sends the key back as the answer. ``facts`` what it is about and ``expires_in`` when its default answer takes
over (see :class:`PromptWait`); a host that shows them sends the key back as
the answer.
Only the outermost prompt of a nested read is announced: a confirm that asks Only the outermost prompt of a nested read is announced: a confirm that asks
a follow-up question while it is already on screen must not notify — and a follow-up question while it is already on screen must not notify — and
@@ -201,7 +217,8 @@ def prompt_wait(
return return
token = _wait_depth.set(_wait_depth.get() + 1) token = _wait_depth.set(_wait_depth.get() + 1)
try: try:
with hook(PromptWait(kind, title, detail, choices)) as source: wait = PromptWait(kind, title, detail, choices, body, facts, expires_in)
with hook(wait) as source:
yield source yield source
finally: finally:
_wait_depth.reset(token) _wait_depth.reset(token)
@@ -554,6 +571,15 @@ def _read_with_source(
return answer.strip() return answer.strip()
#: Titles for the prompts whose own text *is* the question: a host gives the
#: title a line of its own and cuts it short, so the question itself goes into
#: the body, where it is read whole.
_ASK_TITLE = "a question is waiting"
_CHOOSE_TITLE = "a choice is waiting"
#: How many option labels a choice prompt names alongside its question.
_MAX_CHOICE_FACTS = 4
def ask( def ask(
prompt: str, prompt: str,
*, *,
@@ -568,7 +594,7 @@ def ask(
if not backend.is_interactive() and default is None: if not backend.is_interactive() and default is None:
msg = f"non-interactive: cannot prompt for {prompt!r}" msg = f"non-interactive: cannot prompt for {prompt!r}"
raise NonInteractiveError(msg) raise NonInteractiveError(msg)
with prompt_wait("ask", prompt) as wait: with prompt_wait("ask", _ASK_TITLE, body=prompt) as wait:
backend.secho(prompt, fg="yellow") backend.secho(prompt, fg="yellow")
source = wait if wait is not None and wait.supports_actions else None source = wait if wait is not None and wait.supports_actions else None
return _read_with_source("Answer", source, default=default, completions=completions) return _read_with_source("Answer", source, default=default, completions=completions)
@@ -577,6 +603,10 @@ def ask(
def ask_secret(prompt: str) -> str: def ask_secret(prompt: str) -> str:
"""Prompt for a secret line (no echo). Never use for values that return to the model. """Prompt for a secret line (no echo). Never use for values that return to the model.
The ``secret`` family is announced, never answered: the prompt is on screen
at the terminal only, since a value typed into a notification would have to
travel to get back to the process.
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`. Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
""" """
backend = get_prompt_backend() backend = get_prompt_backend()
@@ -616,8 +646,9 @@ def choose(
msg = f"non-interactive: cannot prompt for {prompt!r}" msg = f"non-interactive: cannot prompt for {prompt!r}"
raise NonInteractiveError(msg) raise NonInteractiveError(msg)
facts = tuple(dict.fromkeys(option.label for option in choices[:_MAX_CHOICE_FACTS]))
while True: while True:
with prompt_wait("choose", prompt): with prompt_wait("choose", _CHOOSE_TITLE, body=prompt, facts=facts):
raw = backend.read_line( raw = backend.read_line(
"Choice", "Choice",
default=default_display, default=default_display,