mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
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:
@@ -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:
|
||||||
|
|||||||
+52
-13
@@ -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
|
||||||
@@ -434,11 +448,40 @@ 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:
|
||||||
|
with self._resolve_lock:
|
||||||
if self._resolve_desktop:
|
if self._resolve_desktop:
|
||||||
self._desktop = load_desktop_channel()
|
self._desktop = load_desktop_channel()
|
||||||
self._resolve_desktop = False
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user