core/cli+prompting: let a prompt name the answers its notification offers

Every button-bearing prompt offered the same three answers, so the ones whose
terminal read takes more than yes or no lost theirs the moment the human
answered from the notification: the limit prompt could not say "stop asking this
turn" (`yyy`), the batch confirm could not say "one at a time" (`m`), a skill
write could not say "this chat" (`s`), and the limit prompt could not be
answered at all — its wait was not even consulted.

`PromptWait` now carries `choices` (a key the terminal read accepts, plus a
label a human reads), `prompt_wait` passes them through, and the notifier sends
them as buttons, appending "Go to terminal" as the way back. The limit prompt
reads both surfaces like a timed confirm does, and answers that used to be
terminal-only tokens arrive as themselves.

A prompt that follows an answer given on the notification — the "why denied"
comment, the per-call prompts after "Review one by one" — is announced at once:
the grace period and the burst throttle exist for prompts the human has not
noticed yet, and they have.
This commit is contained in:
2026-10-24 01:48:22 +08:00
parent b7044bbba7
commit 3a54745d01
4 changed files with 91 additions and 18 deletions
+33 -3
View File
@@ -69,11 +69,33 @@ _CONTINUE_PROMPT = "Raise limit and continue? [Y/n/yyy] (yyy = stop asking this
_CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"}) _CONTINUE_YES_TOKENS = frozenset({"", "y", "yes"})
_CONTINUE_AUTO_TOKEN = "yyy" _CONTINUE_AUTO_TOKEN = "yyy"
# The answers each prompt accepts, as notification buttons: the key is the
# token the terminal read takes, the label is what a human reads there.
_LIMIT_CHOICES: tuple[tuple[str, str], ...] = (
("y", "Continue"),
(_CONTINUE_AUTO_TOKEN, "Continue, stop asking"),
("n", "Stop here"),
)
# Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one # Batch confirm answers: ``y`` allows the round's calls, ``m`` reviews them one
# at a time through the per-call prompt. # at a time through the per-call prompt.
_BATCH_YES_TOKENS = frozenset({"y", "yes"}) _BATCH_YES_TOKENS = frozenset({"y", "yes"})
_BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"}) _BATCH_MANUAL_TOKENS = frozenset({"m", "manual", "each"})
_BATCH_CHOICES: tuple[tuple[str, str], ...] = (
("y", "Allow all"),
("m", "Review one by one"),
("n", "Deny"),
)
# Skill writes: ``o`` allows this change, ``s`` the whole directory for the rest
# of the chat.
_SKILL_CHOICES: tuple[tuple[str, str], ...] = (
("o", "Allow once"),
("s", "This chat"),
("n", "Deny"),
)
@dataclass @dataclass
class _AutoContinue: class _AutoContinue:
@@ -202,9 +224,17 @@ def _decide_continue_limit(reason: str) -> bool:
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
return False return False
with prompt_wait("limit", "limit reached — continue?", reason): with prompt_wait("limit", "limit reached — continue?", reason, choices=_LIMIT_CHOICES) as wait:
_echo_continue_limit(reason) _echo_continue_limit(reason)
source = _answer_source(wait)
if source is None:
token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower() token = backend.read_line(_CONTINUE_PROMPT, default="y").strip().lower()
else:
# Same read as the timed confirms: stdin or the notification,
# whichever answers first, with the terminal's default on silence.
token = (_read_answer(None, wait) or "").strip().lower()
if not token:
token = "y"
if token == _CONTINUE_AUTO_TOKEN: if token == _CONTINUE_AUTO_TOKEN:
_auto_continue.turn = True _auto_continue.turn = True
return True return True
@@ -453,7 +483,7 @@ def prompt_skill_write_confirm(
try: try:
with ( with (
pause_task_cancel_for_prompt(), pause_task_cancel_for_prompt(),
prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail) as wait, prompt_wait("skills", f"allow {action} in {skill_dir.name!r}?", detail, choices=_SKILL_CHOICES) as wait,
): ):
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
@@ -591,7 +621,7 @@ def _prompt_confirm_batch_sync(requests: Sequence[ToolConfirmRequest]) -> BatchC
# Non-interactive: deny, like the default of the single prompt. # Non-interactive: deny, like the default of the single prompt.
return BatchConfirmDecision(allowed=False) return BatchConfirmDecision(allowed=False)
names = ", ".join(request.name for request in requests) names = ", ".join(request.name for request in requests)
with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names) as wait: with prompt_wait("batch", f"{len(requests)} tool calls need confirmation", names, choices=_BATCH_CHOICES) as wait:
backend.echo() backend.echo()
backend.secho(format_batch_confirm_box(requests), fg="yellow") backend.secho(format_batch_confirm_box(requests), fg="yellow")
backend.secho("m = review them one call at a time", fg="bright_black") backend.secho("m = review them one call at a time", fg="bright_black")
+40 -10
View File
@@ -3,10 +3,12 @@
A prompt blocks the turn, so someone who switched to another window learns about A prompt blocks the turn, so someone who switched to another window learns about
it only when they come back. The notifier announces a prompt once it has waited it only when they come back. The notifier announces a prompt once it has waited
``grace_seconds`` — answering at the terminal stays silent — and, where the ``grace_seconds`` — answering at the terminal stays silent — and, where the
platform allows it, offers the answer itself: allow / deny / review buttons for platform allows it, offers the answer itself: the prompt's own answers as
a confirmation, a reply field for a question. A prompt that can be answered from buttons (allow / deny / review for a confirmation), a reply field for a
the notification reads from both surfaces from its first poll, since the question. A prompt that can be answered from the notification reads from both
announcement only appears once the grace period is over. surfaces from its first poll, since the announcement only appears once the
grace period is over. A prompt that follows an answer given on the notification
is announced at once: the human is already looking at it.
Two channels. The desktop one is the optional ``desktop-notifier`` extra Two channels. The desktop one is the optional ``desktop-notifier`` extra
(``pip install plyngent[notify]``); the terminal one writes an OSC escape (``pip install plyngent[notify]``); the terminal one writes an OSC escape
@@ -48,6 +50,7 @@ _ANSWER_CAPABILITY: dict[str, str] = {
"policy": "buttons", "policy": "buttons",
"access": "buttons", "access": "buttons",
"skills": "buttons", "skills": "buttons",
"limit": "buttons",
"ask": "reply", "ask": "reply",
} }
@@ -339,12 +342,16 @@ class WaitHandle:
#: one from the first moment it blocks — the notification itself is only #: one from the first moment it blocks — the notification itself is only
#: shown after the grace period. #: shown after the grace period.
supports_actions: bool supports_actions: bool
#: Whether this prompt continues an exchange the human answered on the
#: notification: it is then announced at once, and not throttled.
handoff: bool
def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None: def __init__(self, notifier: WaitNotifier, wait: PromptWait) -> None:
self.notifier = notifier self.notifier = notifier
self.wait = wait self.wait = wait
self.identifier = None self.identifier = None
self.supports_actions = notifier.answer_capability(wait.kind) is not None self.supports_actions = notifier.answer_capability(wait.kind) is not None
self.handoff = notifier.take_handoff()
self._answers: SimpleQueue[str] = SimpleQueue() self._answers: SimpleQueue[str] = SimpleQueue()
self._timer: threading.Timer | None = None self._timer: threading.Timer | None = None
self._closed: bool = False self._closed: bool = False
@@ -364,10 +371,14 @@ class WaitHandle:
# "review" means "I will answer in the terminal": withdraw and wait. # "review" means "I will answer in the terminal": withdraw and wait.
self.notifier.dismiss(self) self.notifier.dismiss(self)
return return
# The human is at the notification now, so a prompt that follows this
# answer is announced at once instead of waiting out the grace period.
self.notifier.handoff()
self._answers.put(key) self._answers.put(key)
def __enter__(self) -> Self: def __enter__(self) -> Self:
timer = threading.Timer(self.notifier.grace_seconds, self._notify) delay = 0.0 if self.handoff else self.notifier.grace_seconds
timer = threading.Timer(delay, self._notify)
timer.daemon = True timer.daemon = True
self._timer = timer self._timer = timer
timer.start() timer.start()
@@ -386,7 +397,7 @@ class WaitHandle:
self.notifier.dismiss(self) self.notifier.dismiss(self)
def _notify(self) -> None: def _notify(self) -> None:
if self._closed or not self.notifier.may_notify(): if self._closed or not self.notifier.may_notify(self):
return return
if not self.notifier.notify_desktop(self): if not self.notifier.notify_desktop(self):
self.notifier.notify_terminal(self.wait) self.notifier.notify_terminal(self.wait)
@@ -407,6 +418,7 @@ class WaitNotifier:
_env: Mapping[str, str] _env: Mapping[str, str]
_clock: Callable[[], float] _clock: Callable[[], float]
_last_sent: float _last_sent: float
_handoff: bool
def __init__( def __init__(
self, self,
@@ -429,6 +441,7 @@ class WaitNotifier:
self._env = os.environ if env is None else env self._env = os.environ if env is None else env
self._clock = clock self._clock = clock
self._last_sent = float("-inf") self._last_sent = float("-inf")
self._handoff = False
# -- hook ------------------------------------------------------------- # # -- hook ------------------------------------------------------------- #
@@ -439,16 +452,27 @@ class WaitNotifier:
def grace_seconds(self) -> float: def grace_seconds(self) -> float:
return self._config.grace_seconds return self._config.grace_seconds
def handoff(self) -> None:
"""Note that the human is answering on the notification right now."""
self._handoff = True
def take_handoff(self) -> bool:
"""Consume the handoff: this prompt is part of the same exchange."""
handoff = self._handoff
self._handoff = False
return handoff
def _wants(self, channel: str) -> bool: def _wants(self, channel: str) -> bool:
channels = self._config.channels channels = self._config.channels
return channel in channels or "auto" in channels return channel in channels or "auto" in channels
def may_notify(self) -> bool: def may_notify(self, handle: WaitHandle) -> bool:
"""Whether a notification is worth sending right now.""" """Whether this prompt is worth announcing right now."""
if not self._config.enabled or not get_prompt_backend().is_interactive(): if not self._config.enabled or not get_prompt_backend().is_interactive():
return False return False
now = self._clock() now = self._clock()
if now - self._last_sent < self._config.min_interval_seconds: # A prompt the human reached through the notification is not a burst.
if not handle.handoff and now - self._last_sent < self._config.min_interval_seconds:
return False return False
self._last_sent = now self._last_sent = now
return True return True
@@ -459,6 +483,12 @@ class WaitNotifier:
return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}" return f"{title}\n{_clean(wait.detail, _MAX_MESSAGE)}"
return title return title
def _buttons(self, wait: PromptWait) -> tuple[tuple[str, str], ...]:
"""The answers *wait* accepts, and always a way back to the terminal."""
if not wait.choices:
return _BUTTONS
return (*wait.choices, (REVIEW, "Go to terminal"))
def desktop_channel(self) -> DesktopChannel | None: def desktop_channel(self) -> DesktopChannel | None:
"""The desktop surface, resolved once (importing the extra on demand).""" """The desktop surface, resolved once (importing the extra on demand)."""
if self._resolve_desktop: if self._resolve_desktop:
@@ -509,7 +539,7 @@ class WaitNotifier:
DesktopRequest( DesktopRequest(
title=_clean(wait.title, _MAX_TITLE), title=_clean(wait.title, _MAX_TITLE),
message=self._message(wait), message=self._message(wait),
buttons=_BUTTONS if capability == "buttons" else (), buttons=self._buttons(wait) if capability == "buttons" else (),
reply_field=capability == "reply", reply_field=capability == "reply",
on_answer=handle.answer, on_answer=handle.answer,
on_reply=handle.answer, on_reply=handle.answer,
+2 -1
View File
@@ -284,7 +284,8 @@ class NotifyConfig(Struct, omit_defaults=True):
"""Desktop notifications shown while a prompt waits on the human. """Desktop notifications shown while a prompt waits on the human.
A prompt is announced only after ``grace_seconds`` of waiting, so answering A prompt is announced only after ``grace_seconds`` of waiting, so answering
at the terminal stays silent; ``min_interval_seconds`` throttles bursts. at the terminal stays silent; ``min_interval_seconds`` throttles bursts, and
a prompt that follows an answer given on the notification skips both.
``channels`` picks the surfaces: ``auto`` tries the desktop first and falls ``channels`` picks the surfaces: ``auto`` tries the desktop first and falls
back to the terminal, ``desktop`` / ``terminal`` pin one of them. back to the terminal, ``desktop`` / ``terminal`` pin one of them.
+15 -3
View File
@@ -126,12 +126,16 @@ class PromptWait:
``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, while ``detail`` carries one optional line the
caller has already redacted. 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.
""" """
kind: str kind: str
title: str title: str
detail: str = "" detail: str = ""
choices: tuple[tuple[str, str], ...] = ()
@runtime_checkable @runtime_checkable
@@ -176,9 +180,17 @@ def get_prompt_wait_hook() -> PromptWaitHook | None:
@contextlib.contextmanager @contextlib.contextmanager
def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnswerSource | None]: def prompt_wait(
kind: str,
title: str,
detail: str = "",
choices: tuple[tuple[str, str], ...] = (),
) -> 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`);
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
interrupt — a second time. interrupt — a second time.
@@ -189,7 +201,7 @@ def prompt_wait(kind: str, title: str, detail: str = "") -> Generator[PromptAnsw
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)) as source: with hook(PromptWait(kind, title, detail, choices)) as source:
yield source yield source
finally: finally:
_wait_depth.reset(token) _wait_depth.reset(token)