core/cli: name the workspace, let the popup expire, ring the bell

A notification said which prompt was waiting but not which chat, and two
windows look alike. The body's last line is now the workspace the chat runs
in, with the home directory shown as `~`, and it follows a session resumed
into — or rebound to — another directory: `ReplState` retargets the installed
notifier instead of freezing the directory the process started in.

A timed prompt has a deadline, the popup did not, so it could stay on screen
announcing an answer window that had already closed. The deadline is now the
popup's own timeout (rounded up to whole seconds); a prompt without one keeps
the old "until dismissed" behaviour.

"Go to terminal" (and a click on the body) withdrew the notification and left
the prompt waiting, but nothing showed the human *where*. No desktop lets a
program raise another window — Wayland has no such call and the X11 ones need
a helper per WM — so the terminal rings its bell instead.
This commit is contained in:
2026-10-24 04:35:04 +08:00
parent 39258b2670
commit ea0827ddfb
3 changed files with 108 additions and 22 deletions
+16 -4
View File
@@ -129,20 +129,27 @@ def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
return text or None
def _setup_hooks(*, interactive: bool, auto_continue: bool, notify: NotifyConfig) -> None:
def _setup_hooks(
*,
interactive: bool,
auto_continue: bool,
notify: NotifyConfig,
workspace: Path | None = None,
) -> None:
"""Install limit hooks (workspace policy is set on ReplState).
``auto_continue`` keeps the PTY/limit hook installed for one-shot runs so
``--auto-continue`` / ``[agent] auto_continue_limits`` can raise limits
without a prompt; otherwise non-interactive runs keep today's deny. The
prompt-wait notifier only makes sense for a human at a terminal, so a
non-interactive run clears it whatever ``[notify]`` says.
non-interactive run clears it whatever ``[notify]`` says. *workspace* is
what a notification names, so two chats are told apart.
"""
from plyngent.cli.notify import install_notifier
from plyngent.prompting import set_prompt_wait_hook
if interactive:
_ = install_notifier(notify)
_ = install_notifier(notify, workspace=workspace)
else:
set_prompt_wait_hook(None)
if interactive or auto_continue:
@@ -258,7 +265,12 @@ async def _run_chat( # noqa: C901, PLR0915 — chat orchestration
_warn_recoverable_providers(store.recoverable_providers)
auto_effective = auto_continue or store.agent_config.auto_continue_limits
_setup_hooks(interactive=interactive, auto_continue=auto_effective, notify=store.notify_config)
_setup_hooks(
interactive=interactive,
auto_continue=auto_effective,
notify=store.notify_config,
workspace=workspace,
)
# --yes forces sticky YOLO; else derive from config.confirm_destructive.
from plyngent.cli.state import YoloMode
+78 -18
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
import asyncio
import contextlib
import importlib.util
import math
import os
import sys
import threading
@@ -96,6 +97,27 @@ def _deadline(wait: PromptWait) -> str:
return _clean(_DEADLINE.format(seconds=wait.expires_in), _MAX_BODY_LINE)
def _expiry(wait: PromptWait) -> int | None:
"""How long the popup may stay up: as long as the prompt waits for an answer.
``None`` keeps it until it is dismissed — a prompt with no deadline has no
moment at which the notification is stale.
"""
if wait.expires_in is None or wait.expires_in <= 0:
return None
return max(1, math.ceil(wait.expires_in))
def _shorten_home(path: Path) -> str:
"""The path as the human types it: ``~`` for the home directory."""
home = Path.home()
if path == home:
return "~"
with contextlib.suppress(ValueError):
return f"~/{path.relative_to(home).as_posix()}"
return str(path)
def _body_parts(wait: PromptWait, *, include_reason: bool) -> list[str]:
"""The lines the prompt asks for, in reading order.
@@ -113,25 +135,23 @@ def _body_parts(wait: PromptWait, *, include_reason: bool) -> list[str]:
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.
def _body(wait: PromptWait, *, include_reason: bool, multiline: bool, source: str = "") -> str:
"""The notification body, with the deadline and the source kept for last.
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)
tail = [line for line in (_deadline(wait), _clean(source, _MAX_BODY_LINE)) if line]
budget = _MAX_MESSAGE - sum(len(line) + 1 for line in tail)
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)
return "\n".join([*kept, *tail]) if multiline else " ".join([*kept, *tail])
# --------------------------------------------------------------------------- #
@@ -150,6 +170,8 @@ class DesktopRequest:
on_answer: Callable[[str], None] | None = None
on_reply: Callable[[str], None] | None = None
on_click: Callable[[], None] | None = None
#: Seconds the popup may stay up; ``None`` keeps it until it is dismissed.
timeout: int | None = None
@runtime_checkable
@@ -240,9 +262,9 @@ class DesktopNotifierChannel:
message=request.message,
# A prompt holds the turn until it is answered, so the
# notification must still be on screen when the human looks
# back. Dismissing is the prompt's own job once it is
# answered, so nothing lingers.
timeout=_NO_EXPIRY,
# back: it stays up until it is dismissed — or, for a prompt
# that gives up on its own, until that deadline.
timeout=_NO_EXPIRY if request.timeout is None else request.timeout,
buttons=buttons,
reply_field=reply,
# Clicking the body is the default action: it takes the
@@ -430,8 +452,7 @@ class WaitHandle:
if self._closed:
return
if key == REVIEW:
# "review" means "I will answer in the terminal": withdraw and wait.
self.notifier.dismiss(self)
self.review()
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.
@@ -446,8 +467,14 @@ class WaitHandle:
self._answers.put(text)
def review(self) -> None:
"""Take the human back to the terminal (a click on the notification)."""
"""Take the human back to the terminal: withdraw, and ring the bell.
Nothing here can raise another application's window — no desktop
exposes that portably — so the terminal rings instead: the prompt is
still waiting there, and the bell says which window holds it.
"""
self.notifier.dismiss(self)
self.notifier.ring()
def __enter__(self) -> Self:
delay = 0.0 if self.handoff else self.notifier.grace_seconds
@@ -492,6 +519,7 @@ class WaitNotifier:
_clock: Callable[[], float]
_last_sent: float
_handoff: bool
_source: str
def __init__(
self,
@@ -501,6 +529,7 @@ class WaitNotifier:
write: Callable[[str], None] | None = None,
env: Mapping[str, str] | None = None,
clock: Callable[[], float] = time.monotonic,
workspace: Path | str | None = None,
) -> None:
self._config = config
if isinstance(desktop, _Resolve):
@@ -515,6 +544,9 @@ class WaitNotifier:
self._clock = clock
self._last_sent = float("-inf")
self._handoff = False
self._source = ""
if workspace is not None:
self.set_workspace(workspace)
# -- hook ------------------------------------------------------------- #
@@ -525,6 +557,19 @@ class WaitNotifier:
def grace_seconds(self) -> float:
return self._config.grace_seconds
def set_workspace(self, workspace: Path | str | None) -> None:
"""Name the directory the prompt belongs to (``~`` for a home path).
A chat can be rebound to another workspace, so the label is kept on the
notifier rather than baked into a notification: several chats look
alike, and which one is waiting is the first thing a human needs.
"""
self._source = "" if workspace is None else _shorten_home(Path(workspace))
def ring(self) -> None:
"""Ring the terminal's bell: the prompt is waiting *there*."""
self._write(_BELL)
def handoff(self) -> None:
"""Note that the human is answering on the notification right now."""
self._handoff = True
@@ -556,11 +601,21 @@ class WaitNotifier:
def _message(self, wait: PromptWait) -> str:
"""The one-line body the terminal channel carries."""
return _body(wait, include_reason=self._config.include_reason, multiline=False)
return _body(
wait,
include_reason=self._config.include_reason,
multiline=False,
source=self._source,
)
def _desktop_message(self, wait: PromptWait) -> str:
"""The body the desktop channel carries: the same lines, kept apart."""
return _body(wait, include_reason=self._config.include_reason, multiline=True)
return _body(
wait,
include_reason=self._config.include_reason,
multiline=True,
source=self._source,
)
def _buttons(self, wait: PromptWait, capability: str) -> tuple[tuple[str, str], ...]:
"""The answers *wait* accepts, and always a way back to the terminal."""
@@ -627,6 +682,7 @@ class WaitNotifier:
on_answer=handle.answer,
on_reply=handle.reply,
on_click=handle.review,
timeout=_expiry(wait),
)
)
except Exception: # noqa: BLE001 - a notification never breaks a prompt
@@ -656,12 +712,16 @@ class WaitNotifier:
channel.clear(identifier)
def install_notifier(config: NotifyConfig) -> WaitNotifier | None:
"""Install the prompt-wait hook from ``[notify]`` (None clears it)."""
def install_notifier(config: NotifyConfig, *, workspace: Path | str | None = None) -> WaitNotifier | None:
"""Install the prompt-wait hook from ``[notify]`` (None clears it).
*workspace* is the directory the chat runs in: a notification names it, so
two waiting chats are told apart.
"""
if not config.enabled:
set_prompt_wait_hook(None)
return None
notifier = WaitNotifier(config)
notifier = WaitNotifier(config, workspace=workspace)
notifier.warm()
set_prompt_wait_hook(notifier)
return notifier
+14
View File
@@ -108,6 +108,7 @@ class ReplState:
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
self.instance_state.workspace.root = self.workspace
self._retarget_notifier(self.workspace)
self._install_config_access()
self._install_skills()
from plyngent.cli.limits import set_auto_continue_default
@@ -711,11 +712,24 @@ class ReplState:
self.workspace = resolved
self.instance_state.workspace_root = resolved
self.instance_state.workspace.root = resolved
# A prompt announced from here names this directory, not the one the
# process started in.
self._retarget_notifier(resolved)
# Project-scope skill roots follow the workspace.
self._install_skills()
if hasattr(self, "agent") and self.agent.tools is not None:
self.agent.tools.set_instance_state(self.instance_state)
@staticmethod
def _retarget_notifier(workspace: Path) -> None:
"""Point an installed prompt notifier at *workspace* (if there is one)."""
from plyngent.cli.notify import WaitNotifier
from plyngent.prompting import get_prompt_wait_hook
hook = get_prompt_wait_hook()
if isinstance(hook, WaitNotifier):
hook.set_workspace(workspace)
def _apply_session_workspace(self, row: SessionRow) -> None:
"""Bind tools/REPL workspace to the session's directory when set."""
if not row.workspace: