mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/cli: render a prompt's body from what it declares
The body was the reason line or nothing, so with the default `include_reason = false` every notification was a bare title. It is now the question, then the reason when the config allows it, then what the prompt is about, and last the deadline — filled in that order until the budget is gone, so a long reason drops out before "no answer in 30s = deny" does. A desktop popup wraps and keeps the lines apart; a terminal escape sequence carries them on one line. Two answers were missing from the surface: a reply prompt had no button at all (a question could not be sent back to the terminal), and the notification body was not clickable. Clicking it is the freedesktop default action, so it now withdraws the notification and leaves the prompt waiting — the same as the "Go to terminal" button. A typed reply is recorded by `reply()`, which keeps a reply that spells "review" an answer; a secret is never announced at all.
This commit is contained in:
+95
-18
@@ -5,10 +5,13 @@ 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 / review for a confirmation), a reply field for a
|
||||||
question. A prompt that can be answered from the notification reads from both
|
question. It says what is asked: a question travels in the body where it is read
|
||||||
surfaces from its first poll, since the announcement only appears once the
|
whole, the object of a confirmation (a program or a file name) rides along with
|
||||||
grace period is over. A prompt that follows an answer given on the notification
|
it, and a prompt that takes a default answer says when not answering gives it. A
|
||||||
is announced at once: the human is already looking at it.
|
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. 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
|
||||||
@@ -62,6 +65,13 @@ _BUTTONS: tuple[tuple[str, str], ...] = (
|
|||||||
|
|
||||||
_MAX_TITLE = 80
|
_MAX_TITLE = 80
|
||||||
_MAX_MESSAGE = 200
|
_MAX_MESSAGE = 200
|
||||||
|
#: One body line is capped so a single long line cannot starve the others.
|
||||||
|
_MAX_BODY_LINE = 100
|
||||||
|
#: Prompt families a notification never announces: the value being asked for
|
||||||
|
#: would have to travel back through a surface that outlives the prompt.
|
||||||
|
_QUIET_KINDS = frozenset({"secret"})
|
||||||
|
#: What not answering means, for a prompt that has a timed default.
|
||||||
|
_DEADLINE = "no answer in {seconds:g}s = deny"
|
||||||
|
|
||||||
#: Seconds a desktop notification may stay up; ``0`` is the freedesktop value
|
#: Seconds a desktop notification may stay up; ``0`` is the freedesktop value
|
||||||
#: for "until dismissed" (``-1`` would hand the notification server's default
|
#: for "until dismissed" (``-1`` would hand the notification server's default
|
||||||
@@ -79,6 +89,51 @@ def _clean(text: str, limit: int) -> str:
|
|||||||
return stripped if len(stripped) <= limit else stripped[: limit - 1] + "…"
|
return stripped if len(stripped) <= limit else stripped[: limit - 1] + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _deadline(wait: PromptWait) -> str:
|
||||||
|
"""What not answering means, when the prompt takes a default answer."""
|
||||||
|
if wait.expires_in is None:
|
||||||
|
return ""
|
||||||
|
return _clean(_DEADLINE.format(seconds=wait.expires_in), _MAX_BODY_LINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _body_parts(wait: PromptWait, *, include_reason: bool) -> list[str]:
|
||||||
|
"""The lines the prompt asks for, in reading order.
|
||||||
|
|
||||||
|
The question first, then the reason when the config allows it, then what the
|
||||||
|
prompt is about: a host that hides the reason still says what is being asked
|
||||||
|
and what it concerns.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if wait.body:
|
||||||
|
parts.append(wait.body)
|
||||||
|
if include_reason and wait.detail:
|
||||||
|
parts.append(wait.detail)
|
||||||
|
if wait.facts:
|
||||||
|
parts.append(" · ".join(wait.facts))
|
||||||
|
return [_clean(part, _MAX_BODY_LINE) for part in parts]
|
||||||
|
|
||||||
|
|
||||||
|
def _body(wait: PromptWait, *, include_reason: bool, multiline: bool) -> str:
|
||||||
|
"""The notification body, with the deadline kept whatever else does not fit.
|
||||||
|
|
||||||
|
The lines are filled in reading order until the budget is gone — a long
|
||||||
|
reason drops out before the deadline does — and each one is capped, so no
|
||||||
|
single line can starve the rest. A desktop popup wraps, so its body keeps
|
||||||
|
the lines apart; a terminal escape sequence carries the body on one line.
|
||||||
|
"""
|
||||||
|
deadline = _deadline(wait)
|
||||||
|
budget = _MAX_MESSAGE - (len(deadline) + 1 if deadline else 0)
|
||||||
|
kept: list[str] = []
|
||||||
|
for line in _body_parts(wait, include_reason=include_reason):
|
||||||
|
if not line or len(line) + 1 > budget:
|
||||||
|
continue
|
||||||
|
kept.append(line)
|
||||||
|
budget -= len(line) + 1
|
||||||
|
if deadline:
|
||||||
|
kept.append(deadline)
|
||||||
|
return "\n".join(kept) if multiline else " ".join(kept)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Desktop channel (optional `desktop-notifier` extra)
|
# Desktop channel (optional `desktop-notifier` extra)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -94,6 +149,7 @@ class DesktopRequest:
|
|||||||
reply_field: bool = False
|
reply_field: bool = False
|
||||||
on_answer: Callable[[str], None] | None = None
|
on_answer: Callable[[str], None] | None = None
|
||||||
on_reply: Callable[[str], None] | None = None
|
on_reply: Callable[[str], None] | None = None
|
||||||
|
on_click: Callable[[], None] | None = None
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -189,6 +245,9 @@ class DesktopNotifierChannel:
|
|||||||
timeout=_NO_EXPIRY,
|
timeout=_NO_EXPIRY,
|
||||||
buttons=buttons,
|
buttons=buttons,
|
||||||
reply_field=reply,
|
reply_field=reply,
|
||||||
|
# Clicking the body is the default action: it takes the
|
||||||
|
# human back to the terminal instead of answering.
|
||||||
|
on_clicked=request.on_click,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -367,10 +426,10 @@ class WaitHandle:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def answer(self, key: str) -> None:
|
def answer(self, key: str) -> None:
|
||||||
"""Record one answer from the notification (a button, or a reply)."""
|
"""Record one answer pressed on the notification (a button)."""
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
if key == REVIEW and self.wait.kind != "ask":
|
if key == REVIEW:
|
||||||
# "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
|
||||||
@@ -379,6 +438,17 @@ class WaitHandle:
|
|||||||
self.notifier.handoff()
|
self.notifier.handoff()
|
||||||
self._answers.put(key)
|
self._answers.put(key)
|
||||||
|
|
||||||
|
def reply(self, text: str) -> None:
|
||||||
|
"""Record one answer typed into the notification's reply field."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self.notifier.handoff()
|
||||||
|
self._answers.put(text)
|
||||||
|
|
||||||
|
def review(self) -> None:
|
||||||
|
"""Take the human back to the terminal (a click on the notification)."""
|
||||||
|
self.notifier.dismiss(self)
|
||||||
|
|
||||||
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
|
||||||
timer = threading.Timer(delay, self._notify)
|
timer = threading.Timer(delay, self._notify)
|
||||||
@@ -473,6 +543,10 @@ class WaitNotifier:
|
|||||||
"""Whether this prompt is worth announcing 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
|
||||||
|
# A secret is never announced: its value would have to come back
|
||||||
|
# through a surface that outlives the prompt.
|
||||||
|
if handle.wait.kind in _QUIET_KINDS:
|
||||||
|
return False
|
||||||
now = self._clock()
|
now = self._clock()
|
||||||
# A prompt the human reached through the notification is not a burst.
|
# 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:
|
if not handle.handoff and now - self._last_sent < self._config.min_interval_seconds:
|
||||||
@@ -481,17 +555,19 @@ class WaitNotifier:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _message(self, wait: PromptWait) -> str:
|
def _message(self, wait: PromptWait) -> str:
|
||||||
"""The body: the reason line, when the config allows it, else nothing.
|
"""The one-line body the terminal channel carries."""
|
||||||
|
return _body(wait, include_reason=self._config.include_reason, multiline=False)
|
||||||
|
|
||||||
Both channels show the title as their own field, so repeating it in the
|
def _desktop_message(self, wait: PromptWait) -> str:
|
||||||
body only makes the notification say the same thing twice.
|
"""The body the desktop channel carries: the same lines, kept apart."""
|
||||||
"""
|
return _body(wait, include_reason=self._config.include_reason, multiline=True)
|
||||||
if self._config.include_reason and wait.detail:
|
|
||||||
return _clean(wait.detail, _MAX_MESSAGE)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _buttons(self, wait: PromptWait) -> 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, and always a way back to the terminal."""
|
||||||
|
if capability == "reply":
|
||||||
|
# A reply field carries the answer, so the only button left is the
|
||||||
|
# way out of the notification.
|
||||||
|
return ((REVIEW, "Go to terminal"),)
|
||||||
if not wait.choices:
|
if not wait.choices:
|
||||||
return _BUTTONS
|
return _BUTTONS
|
||||||
return (*wait.choices, (REVIEW, "Go to terminal"))
|
return (*wait.choices, (REVIEW, "Go to terminal"))
|
||||||
@@ -540,16 +616,17 @@ class WaitNotifier:
|
|||||||
if channel is None:
|
if channel is None:
|
||||||
return False
|
return False
|
||||||
wait = handle.wait
|
wait = handle.wait
|
||||||
capability = self.answer_capability(wait.kind)
|
capability = self.answer_capability(wait.kind) or ""
|
||||||
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._desktop_message(wait),
|
||||||
buttons=self._buttons(wait) if capability == "buttons" else (),
|
buttons=self._buttons(wait, capability) if capability else (),
|
||||||
reply_field=capability == "reply",
|
reply_field=capability == "reply",
|
||||||
on_answer=handle.answer,
|
on_answer=handle.answer,
|
||||||
on_reply=handle.answer,
|
on_reply=handle.reply,
|
||||||
|
on_click=handle.review,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - a notification never breaks a prompt
|
except Exception: # noqa: BLE001 - a notification never breaks a prompt
|
||||||
|
|||||||
Reference in New Issue
Block a user