From ea0827ddfbf851543f99657f38416b3cf2b19394 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 24 Oct 2026 04:35:04 +0800 Subject: [PATCH] core/cli: name the workspace, let the popup expire, ring the bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/plyngent/cli/app.py | 20 ++++++-- src/plyngent/cli/notify.py | 96 +++++++++++++++++++++++++++++++------- src/plyngent/cli/state.py | 14 ++++++ 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 4fbf5d3..d3e8792 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -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 diff --git a/src/plyngent/cli/notify.py b/src/plyngent/cli/notify.py index a620552..e354994 100644 --- a/src/plyngent/cli/notify.py +++ b/src/plyngent/cli/notify.py @@ -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 diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index ba6a7db..e19e68b 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -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: