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``,
``choose``, ``policy``, ``access``, ``skills``, ``limit``) so the host can
tailor what it shows and which answers it offers; ``title`` is short and
safe to surface anywhere, while ``detail`` carries one optional line the
caller has already redacted. ``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.
safe to surface anywhere. ``body`` is the prompt itself — a question, which
the host shows as it stands — while ``detail`` carries one optional reason
line the caller has already redacted: it can name a command or a URL, so a
host shows it only when the human allowed that. ``facts`` are short items
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
title: str
detail: str = ""
choices: tuple[tuple[str, str], ...] = ()
body: str = ""
facts: tuple[str, ...] = ()
expires_in: float | None = None
@runtime_checkable
@@ -185,11 +195,17 @@ def prompt_wait(
title: str,
detail: str = "",
choices: tuple[tuple[str, str], ...] = (),
*,
body: str = "",
facts: tuple[str, ...] = (),
expires_in: float | None = None,
) -> Generator[PromptAnswerSource | None]:
"""Announce a blocking prompt to the host; yields its answer source (or None).
``choices`` says which answers this prompt accepts (see :class:`PromptWait`);
a host that shows them sends the key back as the answer.
``choices`` says which answers this prompt accepts, ``body`` what it asks,
``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
a follow-up question while it is already on screen must not notify — and
@@ -201,7 +217,8 @@ def prompt_wait(
return
token = _wait_depth.set(_wait_depth.get() + 1)
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
finally:
_wait_depth.reset(token)
@@ -554,6 +571,15 @@ def _read_with_source(
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(
prompt: str,
*,
@@ -568,7 +594,7 @@ def ask(
if not backend.is_interactive() and default is None:
msg = f"non-interactive: cannot prompt for {prompt!r}"
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")
source = wait if wait is not None and wait.supports_actions else None
return _read_with_source("Answer", source, default=default, completions=completions)
@@ -577,6 +603,10 @@ def ask(
def ask_secret(prompt: str) -> str:
"""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`.
"""
backend = get_prompt_backend()
@@ -616,8 +646,9 @@ def choose(
msg = f"non-interactive: cannot prompt for {prompt!r}"
raise NonInteractiveError(msg)
facts = tuple(dict.fromkeys(option.label for option in choices[:_MAX_CHOICE_FACTS]))
while True:
with prompt_wait("choose", prompt):
with prompt_wait("choose", _CHOOSE_TITLE, body=prompt, facts=facts):
raw = backend.read_line(
"Choice",
default=default_display,