mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
865d314234
A prompt answered from the notification reads both surfaces; the terminal still shows the keys it takes, which a button cannot say.
154 lines
8.2 KiB
Markdown
154 lines
8.2 KiB
Markdown
# Prompt notifications
|
|
|
|
A turn stops while a prompt waits on the human. If you switched to another
|
|
window, the wait is invisible until you come back — so the CLI announces it.
|
|
|
|
A prompt is announced only after `grace_seconds` of waiting (5s by default), so
|
|
answering at the terminal stays silent. Bursts are throttled by
|
|
`min_interval_seconds`.
|
|
|
|
A notification says what is asked, not what would be run. The title is a short
|
|
label; the body carries, in this order:
|
|
|
|
| Line | When |
|
|
|---|---|
|
|
| the question itself | an `ask_user_line` question, an `ask_user_choice` prompt |
|
|
| the reason line | only with `include_reason = true`: it can name a command, argv or URL, and a notification outlives the prompt (and may be mirrored to a lock screen) |
|
|
| the object | a confirmation or a batch: the program's name (`program: rm`), the file's (`target: todo.md`), the tools of a batch, `kind: directory` for an access request, the skill directory — never an argument, a URL or a piece of code |
|
|
| `no answer in 30s = deny` | a prompt that takes a default answer on timeout (command denylist, directory access, skill write) |
|
|
| the workspace | always: the directory the chat runs in, with the home directory shown as `~` (`~/Projects/plyngent`), so several waiting chats are told apart — it follows a session resumed in another workspace |
|
|
|
|
The lines are filled in that order until the body is full and each one is
|
|
capped, so a long reason drops out before the deadline and the workspace do.
|
|
The deadline is the one line a human must not miss, because not answering *is*
|
|
an answer there; the workspace comes last and is kept for the same reason. A
|
|
desktop popup wraps and keeps the lines apart; the terminal channel carries the
|
|
same body on a single line.
|
|
|
|
## Configuration
|
|
|
|
```toml
|
|
[notify]
|
|
enabled = true
|
|
grace_seconds = 5.0
|
|
min_interval_seconds = 10.0
|
|
channels = ["auto"] # auto | desktop | terminal
|
|
actions = true # offer the answer, not just the news
|
|
include_reason = false
|
|
protocol = "" # terminal protocol override: osc777 / osc9 / bell
|
|
```
|
|
|
|
`channels = ["auto"]` tries the desktop first and falls back to the terminal;
|
|
`["desktop"]` and `["terminal"]` pin one of them. Notifications are only sent
|
|
from an interactive run — a one-shot `plyngent "…"` never waits on a human, and
|
|
a prompt answered inside the grace period is never announced at all.
|
|
|
|
## What is answered where
|
|
|
|
| Prompt | Notification offers |
|
|
|---|---|
|
|
| tool confirm (single) | Allow once / Deny |
|
|
| a round's batch of confirms | Allow all / Review one by one / Deny |
|
|
| command denylist, `fetch_local` private host | Allow once / Deny |
|
|
| directory-access request | same two (the level asked for, not a lower one) |
|
|
| skill write | Allow once / This chat / Deny |
|
|
| limit-continue | Continue / Continue, stop asking / Stop here |
|
|
| `ask_user_line` | a reply field — the answer *is* the text you type |
|
|
| `ask_user_choice`, `ask_user_form` | nothing (terminal only): the question and its options are in the body |
|
|
| PTY secrets | nothing at all: a secret prompt is never announced |
|
|
|
|
Each prompt says which answers it accepts (`prompt_wait(..., choices=…)`), and
|
|
those are the buttons; one that names none gets Allow once / Deny. There is no
|
|
"go to terminal" button: it could not do what it says — no desktop lets a
|
|
program raise another application's window (Wayland has no such call, the X11
|
|
ones need a helper per window manager) — and the popup's own close button does
|
|
as much. Clicking the body (the platform's default action) or closing the popup
|
|
withdraws the notification and leaves the prompt waiting; the prompt is not
|
|
announced a second time.
|
|
|
|
What says *where* to look is the bell: every notification that went to the
|
|
desktop also rings the terminal's bell. The announcement only happens when
|
|
nobody answered inside the grace period, so the human is at some other window —
|
|
possibly another virtual desktop, where a popup is easy to miss — and the bell
|
|
is the only portable cue. It is the terminal's own bell, so a terminal with its
|
|
bell switched off stays silent.
|
|
|
|
An answer read from the notification is treated exactly like a typed one —
|
|
`allow` is the `y` the prompt accepts, `deny` is `n` — and it never records a
|
|
session grant: the button means "this once", which is why a prompt with an
|
|
extra answer (this chat, stop asking) offers it as a button of its own. Text
|
|
typed into a reply field is the answer, whatever it spells. A secret prompt is
|
|
announced nowhere and echoable from nothing: its value would have to travel
|
|
back through a surface that outlives it.
|
|
|
|
A prompt that can be answered from the notification reads both surfaces while it
|
|
blocks, from the first moment — the notification itself only shows up after
|
|
`grace_seconds`, so waiting for it to appear before listening would block on the
|
|
terminal alone. The terminal keeps its own prompt line with the keys it accepts —
|
|
a button elsewhere does not say what typing here would do. Typing at the terminal
|
|
always wins; a readline-only convenience
|
|
(Tab completion on `ask_user_line`) is what a question gives up in exchange.
|
|
|
|
A prompt that *follows* an answer given on the notification — the "why denied"
|
|
comment, the per-call prompts after "Review one by one" — is announced at once,
|
|
with no grace period and past the burst throttle: the human is looking at the
|
|
notification, and the follow-up is the next thing they must answer.
|
|
|
|
## Desktop channel
|
|
|
|
The desktop channel needs the optional extra:
|
|
|
|
```console
|
|
pip install 'plyngent[notify]'
|
|
```
|
|
|
|
It wraps `desktop-notifier`, which talks to `org.freedesktop.Notifications` on
|
|
Linux, Notification Center on macOS and WinRT on Windows. Buttons need a
|
|
notification server that renders them (KDE Plasma and GNOME Shell do; a bare
|
|
`dunst` may not, and then the notification is text-only without any error). The
|
|
reply field is KDE-only in practice: it is the `inline-reply` capability plus the
|
|
`x-kde-reply-submit-button-text` hint, which only KNotifications implements.
|
|
|
|
Notifications are shown from the library's async API on a loop that runs in a
|
|
daemon thread, because callbacks only fire while the loop that owns the
|
|
notification is running.
|
|
|
|
The notification stays on screen: it is sent with no expiry (`timeout = 0`, the
|
|
freedesktop value for "until dismissed"), so the server does not slide it into
|
|
the tray a few seconds later. That is the point of it — the turn is blocked
|
|
until the prompt is answered, and a collapsed notification is one the human
|
|
would find only on coming back anyway. A prompt that gives up on its own is the
|
|
exception: the popup is sent with its deadline as its timeout (30 seconds for a
|
|
prompt that denies after 30s), so the notification disappears when the answer
|
|
window closes instead of announcing a prompt that is already gone. Urgency stays
|
|
at the library default: on KDE Plasma, `critical` keeps the notification up just
|
|
the same but paints the popup as urgent, and on every platform it asks the Focus
|
|
mode to let it through — which a prompt has no business doing. Once the prompt
|
|
is answered, the notification is dismissed.
|
|
|
|
## Terminal channel
|
|
|
|
No dependency and no extra: the CLI writes an escape sequence to `/dev/tty`
|
|
(never stdout, so a redirect cannot swallow it) and the terminal turns it into a
|
|
native notification.
|
|
|
|
| Protocol | Sequence | Terminals |
|
|
|---|---|---|
|
|
| OSC 777 | `ESC ] 777 ; notify ; title ; body` | Ghostty, WezTerm, Warp, Contour, VTE (GNOME Terminal, Tilix, Terminator), rxvt, Windows Terminal |
|
|
| OSC 9 | `ESC ] 9 ; title: body` | iTerm2, Apple Terminal, ConEmu |
|
|
| bell | `BEL` | everything else |
|
|
|
|
Detection reads the environment only (`TERM_PROGRAM`, `TERM`, `WT_SESSION`,
|
|
`VTE_VERSION`), so it never blocks and never touches the input stream;
|
|
`protocol` pins it. Inside tmux the sequence is wrapped in DCS passthrough —
|
|
tmux needs `set -g allow-passthrough on` — and inside GNU Screen in a Screen
|
|
DCS. kitty speaks OSC 99 only, which is not implemented, so it keeps the bell;
|
|
install the extra to reach it through the desktop instead.
|
|
|
|
## Failure is always silent
|
|
|
|
A missing extra, a desktop without a notification server, a refused write: each
|
|
falls through to the next channel and never raises into the prompt. There is no
|
|
sound, no remote push and no notification when a turn merely ends — only when a
|
|
prompt is waiting for you.
|