mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
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:
+41
-10
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user