core/cli+prompting: hear the notification while a prompt waits

Whether a prompt could be answered from its notification was decided by
`notify_desktop`, which only runs once the grace period is over — by then the
prompt had already committed to the plain terminal read. A pressed button or a
typed reply landed on a queue nobody read, and the human had to come back to the
terminal to answer the prompt the notification was showing.

A wait now asks up front whether an answer may arrive (`answer_capability`: the
kind, `actions`, the channel and the platform's capabilities) and listens on both
surfaces from its first poll. The desktop channel is resolved once, in the
background from `install_notifier`, so the capability probe never lands on a
prompt that is already waiting.

`confirm()` takes an optional `source` (the form's submit confirm and every
limits.py confirmation go through it), which also leaves one place — not two —
parsing `allow` / `deny` into a yes or no.
This commit is contained in:
2026-10-23 10:24:46 +08:00
parent b2d6acae26
commit 80ad34f8fe
3 changed files with 87 additions and 35 deletions
+1 -16
View File
@@ -236,22 +236,7 @@ async def prompt_continue_limit_async(reason: str) -> bool:
def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool: def _confirm_with(wait: PromptAnswerSource | None, prompt: str, *, default: bool) -> bool:
"""Yes/no, also taking an answer that arrived through a notification.""" """Yes/no, also taking an answer that arrived through a notification."""
source = _answer_source(wait) return confirm(prompt, default=default, source=_answer_source(wait))
if source is None:
return confirm(prompt, default=default)
suffix = "[Y/n]" if default else "[y/N]"
with contextlib.suppress(OSError):
_ = sys.stderr.write(f"{prompt} {suffix}: ")
_ = sys.stderr.flush()
try:
raw = _read_answer(None, wait)
except (KeyboardInterrupt, EOFError) as exc:
msg = "prompt cancelled"
raise NonInteractiveError(msg) from exc
token = (raw or "").strip().lower()
if not token:
return default
return token in {"y", "yes"}
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str: def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool | str:
+54 -15
View File
@@ -4,7 +4,9 @@ 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: allow / deny / review buttons for
a confirmation, a reply field for a question. a confirmation, a reply field for a question. A prompt that can be answered from
the notification reads from both surfaces from its first poll, since the
announcement only appears once the grace period is over.
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
@@ -38,8 +40,16 @@ ALLOW = "allow"
DENY = "deny" DENY = "deny"
REVIEW = "review" REVIEW = "review"
#: Kinds whose notification carries answer buttons (``ask`` gets a reply field). #: Kinds whose notification carries an answer, and the capability it needs: a
_ANSWER_KINDS = frozenset({"confirm", "batch", "policy", "access", "skills"}) #: confirmation gets buttons, a question gets a reply field.
_ANSWER_CAPABILITY: dict[str, str] = {
"confirm": "buttons",
"batch": "buttons",
"policy": "buttons",
"access": "buttons",
"skills": "buttons",
"ask": "reply",
}
_BUTTONS: tuple[tuple[str, str], ...] = ( _BUTTONS: tuple[tuple[str, str], ...] = (
(ALLOW, "Allow once"), (ALLOW, "Allow once"),
@@ -312,14 +322,16 @@ class WaitHandle:
notifier: WaitNotifier notifier: WaitNotifier
wait: PromptWait wait: PromptWait
identifier: str | None identifier: str | None
#: Whether the notification offered answers, so a read may poll for them. #: Whether an answer may arrive from the notification, so a read polls for
#: one from the first moment it blocks — the notification itself is only
#: shown after the grace period.
supports_actions: bool supports_actions: 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 = False self.supports_actions = notifier.answer_capability(wait.kind) is not None
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
@@ -377,6 +389,7 @@ class WaitNotifier:
_config: NotifyConfig _config: NotifyConfig
_resolve_desktop: bool _resolve_desktop: bool
_desktop: DesktopChannel | None _desktop: DesktopChannel | None
_resolve_lock: threading.Lock
_write: Callable[[str], None] _write: Callable[[str], None]
_env: Mapping[str, str] _env: Mapping[str, str]
_clock: Callable[[], float] _clock: Callable[[], float]
@@ -398,6 +411,7 @@ class WaitNotifier:
else: else:
self._resolve_desktop = False self._resolve_desktop = False
self._desktop = desktop self._desktop = desktop
self._resolve_lock = threading.Lock()
self._write = write or write_terminal self._write = write or write_terminal
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
@@ -435,10 +449,39 @@ class WaitNotifier:
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:
self._desktop = load_desktop_channel() with self._resolve_lock:
self._resolve_desktop = False if self._resolve_desktop:
self._desktop = load_desktop_channel()
self._resolve_desktop = False
return self._desktop return self._desktop
def answer_capability(self, kind: str) -> str | None:
"""The capability *kind*'s answer needs (``buttons`` / ``reply``), or None.
Answered before the notification is shown, so a prompt knows from the
start of its wait whether an answer may arrive.
"""
needed = _ANSWER_CAPABILITY.get(kind)
if needed is None or not self._config.actions or not self._wants("desktop"):
return None
channel = self.desktop_channel()
if channel is None or needed not in channel.capabilities():
return None
return needed
def warm(self) -> None:
"""Resolve the desktop channel off the caller's thread.
Probing the platform's capabilities costs a round trip to the
notification server, so the first prompt of a run must not pay for it
while the human is already waiting.
"""
if not self._resolve_desktop or not self._config.actions or not self._wants("desktop"):
return
if importlib.util.find_spec("desktop_notifier") is None:
return # no extra: the terminal channel is all there is
threading.Thread(target=self.desktop_channel, name="plyngent-notify-warm", daemon=True).start()
def notify_desktop(self, handle: WaitHandle) -> bool: def notify_desktop(self, handle: WaitHandle) -> bool:
"""Show the desktop notification; False when it could not be shown.""" """Show the desktop notification; False when it could not be shown."""
if not self._wants("desktop"): if not self._wants("desktop"):
@@ -447,25 +490,20 @@ class WaitNotifier:
if channel is None: if channel is None:
return False return False
wait = handle.wait wait = handle.wait
capabilities = channel.capabilities() capability = self.answer_capability(wait.kind)
buttons: tuple[tuple[str, str], ...] = ()
if self._config.actions and wait.kind in _ANSWER_KINDS and "buttons" in capabilities:
buttons = _BUTTONS
reply = self._config.actions and wait.kind == "ask" and "reply" in capabilities
try: try:
handle.identifier = channel.send( handle.identifier = channel.send(
DesktopRequest( DesktopRequest(
title=_clean(wait.title, _MAX_TITLE), title=_clean(wait.title, _MAX_TITLE),
message=self._message(wait), message=self._message(wait),
buttons=buttons, buttons=_BUTTONS if capability == "buttons" else (),
reply_field=reply, reply_field=capability == "reply",
on_answer=handle.answer, on_answer=handle.answer,
on_reply=handle.answer, on_reply=handle.answer,
) )
) )
except Exception: # noqa: BLE001 - a notification never breaks a prompt except Exception: # noqa: BLE001 - a notification never breaks a prompt
return False return False
handle.supports_actions = bool(buttons) or reply
return True return True
def notify_terminal(self, wait: PromptWait) -> None: def notify_terminal(self, wait: PromptWait) -> None:
@@ -497,5 +535,6 @@ def install_notifier(config: NotifyConfig) -> WaitNotifier | None:
set_prompt_wait_hook(None) set_prompt_wait_hook(None)
return None return None
notifier = WaitNotifier(config) notifier = WaitNotifier(config)
notifier.warm()
set_prompt_wait_hook(notifier) set_prompt_wait_hook(notifier)
return notifier return notifier
+32 -4
View File
@@ -140,7 +140,12 @@ class PromptAnswerSource(Protocol):
@property @property
def supports_actions(self) -> bool: def supports_actions(self) -> bool:
"""Whether :meth:`take` can answer this prompt at all.""" """Whether an answer may arrive while the prompt waits.
A surface that will offer the answer says so before it has offered it —
the notification is only shown after the host's grace period — so a read
polls :meth:`take` from its first moment instead of deciding once.
"""
... ...
def take(self) -> str | None: def take(self) -> str | None:
@@ -619,8 +624,26 @@ def choose(
backend.echo("Invalid choice; pick a listed option.") backend.echo("Invalid choice; pick a listed option.")
def confirm(prompt: str, *, default: bool = False) -> bool: #: Answers a surface spells out on top of the ``y`` a confirm already accepts.
"""Yes/no confirm via the active backend. _YES = frozenset({"allow", "y", "yes"})
def _confirm_from_source(prompt: str, source: PromptAnswerSource, *, default: bool) -> bool:
"""Yes/no for a prompt a surface may answer, reading the terminal as well."""
hint = "[Y/n]" if default else "[y/N]"
try:
answer = _read_with_source(f"{prompt} {hint}", source, default="")
except (KeyboardInterrupt, EOFError) as exc:
msg = "prompt cancelled"
raise NonInteractiveError(msg) from exc
token = answer.strip().lower()
if not token:
return default
return token in _YES
def confirm(prompt: str, *, default: bool = False, source: PromptAnswerSource | None = None) -> bool:
"""Yes/no confirm via the active backend, or from *source* when given.
Non-interactive backends return ``default`` without blocking. Non-interactive backends return ``default`` without blocking.
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`. Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
@@ -628,8 +651,13 @@ def confirm(prompt: str, *, default: bool = False) -> bool:
backend = get_prompt_backend() backend = get_prompt_backend()
if not backend.is_interactive(): if not backend.is_interactive():
return default return default
if source is not None:
return _confirm_from_source(prompt, source, default=default)
backend.echo() backend.echo()
with prompt_wait("confirm", prompt): with prompt_wait("confirm", prompt) as wait:
offered = wait if wait is not None and wait.supports_actions else None
if offered is not None:
return _confirm_from_source(prompt, offered, default=default)
return backend.confirm(prompt, default=default) return backend.confirm(prompt, default=default)