core/cli: drop the go-to-terminal button and ring the bell when a popup goes out

"Go to terminal" promised something no desktop can do — raise another
application's window — and the popup's own close button does as much, so it
was a third button that answered nothing. `REVIEW` leaves the answer
vocabulary; a click on the body (the platform's default action) or a close
still withdraws the notification and leaves the prompt waiting.

The bell now rings as the notification is shown. A prompt is only announced
after `grace_seconds` of nobody answering it, so the human is at some other
window — possibly another virtual desktop, where a popup is easy to miss — and
the terminal's bell is the only portable cue that says where to look. It rings
once per announcement; withdrawing the popup afterwards stays silent.
This commit is contained in:
2026-10-24 04:40:39 +08:00
parent 7452224793
commit a7abc50aa5
+31 -30
View File
@@ -4,19 +4,21 @@ 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: the prompt's own answers as platform allows it, offers the answer itself: the prompt's own answers as
buttons (allow / deny / review for a confirmation), a reply field for a buttons (allow / deny for a confirmation), a reply field for a question. It says
question. It says what is asked: a question travels in the body where it is read what is asked: a question travels in the body where it is read whole, the object
whole, the object of a confirmation (a program or a file name) rides along with of a confirmation (a program or a file name) rides along with it, a prompt that
it, and a prompt that takes a default answer says when not answering gives it. A takes a default answer says when not answering gives it, and the workspace it
prompt that can be answered from the notification reads from both surfaces from belongs to comes last. A prompt that can be answered from the notification reads
its first poll, since the announcement only appears once the grace period is from both surfaces from its first poll, since the announcement only appears once
over. A prompt that follows an answer given on the notification is announced at the grace period is over. A prompt that follows an answer given on the
once: the human is already looking at it. 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
sequence to the controlling terminal, which every modern terminal turns into a sequence to the controlling terminal, which every modern terminal turns into a
native notification without any dependency at all. native notification without any dependency at all. A notification that went to
the desktop also rings the terminal's bell: the popup may be on another virtual
desktop, and no desktop lets a program raise another application's window.
""" """
from __future__ import annotations from __future__ import annotations
@@ -44,7 +46,6 @@ if TYPE_CHECKING:
ALLOW = "allow" ALLOW = "allow"
DENY = "deny" DENY = "deny"
REVIEW = "review"
#: Kinds whose notification carries an answer, and the capability it needs: a #: Kinds whose notification carries an answer, and the capability it needs: a
#: confirmation gets buttons, a question gets a reply field. #: confirmation gets buttons, a question gets a reply field.
@@ -58,10 +59,10 @@ _ANSWER_CAPABILITY: dict[str, str] = {
"ask": "reply", "ask": "reply",
} }
#: The answers a confirmation offers when the prompt names none of its own.
_BUTTONS: tuple[tuple[str, str], ...] = ( _BUTTONS: tuple[tuple[str, str], ...] = (
(ALLOW, "Allow once"), (ALLOW, "Allow once"),
(DENY, "Deny"), (DENY, "Deny"),
(REVIEW, "Go to terminal"),
) )
_MAX_TITLE = 80 _MAX_TITLE = 80
@@ -451,9 +452,6 @@ class WaitHandle:
"""Record one answer pressed on the notification (a button).""" """Record one answer pressed on the notification (a button)."""
if self._closed: if self._closed:
return return
if key == REVIEW:
self.review()
return
# The human is at the notification now, so a prompt that follows this # 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. # answer is announced at once instead of waiting out the grace period.
self.notifier.handoff() self.notifier.handoff()
@@ -466,15 +464,15 @@ class WaitHandle:
self.notifier.handoff() self.notifier.handoff()
self._answers.put(text) self._answers.put(text)
def review(self) -> None: def withdraw(self) -> None:
"""Take the human back to the terminal: withdraw, and ring the bell. """Take the notification down; the prompt keeps waiting at the terminal.
Nothing here can raise another application's window — no desktop This is what a click on the body does, and the popup's own close button
exposes that portably — so the terminal rings instead: the prompt is means the same. Nothing can raise another application's window (no
still waiting there, and the bell says which window holds it. desktop exposes that portably), which is why the bell — rung as the
notification went out — is what said where the prompt is.
""" """
self.notifier.dismiss(self) self.notifier.dismiss(self)
self.notifier.ring()
def __enter__(self) -> Self: def __enter__(self) -> Self:
delay = 0.0 if self.handoff else self.notifier.grace_seconds delay = 0.0 if self.handoff else self.notifier.grace_seconds
@@ -499,8 +497,13 @@ class WaitHandle:
def _notify(self) -> None: def _notify(self) -> None:
if self._closed or not self.notifier.may_notify(self): if self._closed or not self.notifier.may_notify(self):
return return
if not self.notifier.notify_desktop(self): if self.notifier.notify_desktop(self):
self.notifier.notify_terminal(self.wait) # The popup went to the desktop, so the human is looking at some
# other window — possibly another virtual desktop, where a popup is
# easy to miss. The terminal rings to say which window waits.
self.notifier.ring()
return
self.notifier.notify_terminal(self.wait)
class WaitNotifier: class WaitNotifier:
@@ -618,14 +621,12 @@ class WaitNotifier:
) )
def _buttons(self, wait: PromptWait, capability: str) -> tuple[tuple[str, str], ...]: def _buttons(self, wait: PromptWait, capability: str) -> tuple[tuple[str, str], ...]:
"""The answers *wait* accepts, and always a way back to the terminal.""" """The answers *wait* accepts; a reply prompt has none to offer."""
if capability == "reply": if capability == "reply":
# A reply field carries the answer, so the only button left is the # The reply field carries the answer; a button beside it would only
# way out of the notification. # be a way out, and the popup's own close is that.
return ((REVIEW, "Go to terminal"),) return ()
if not wait.choices: return wait.choices or _BUTTONS
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)."""
@@ -681,7 +682,7 @@ class WaitNotifier:
reply_field=capability == "reply", reply_field=capability == "reply",
on_answer=handle.answer, on_answer=handle.answer,
on_reply=handle.reply, on_reply=handle.reply,
on_click=handle.review, on_click=handle.withdraw,
timeout=_expiry(wait), timeout=_expiry(wait),
) )
) )