From 3691d8976dcd6b70ea8852261e16786c3caef4c1 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Mon, 20 Jul 2026 22:28:05 +0800 Subject: [PATCH 1/2] feat: debounce burst notifications and add quiet mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two config-gated notification throttles that share one cross-process file-backed queue (notify_queue), since there is no resident daemon: - notify_debounce_seconds: when several sessions turn 🔴 at once, the leading edge fires immediately and the rest are merged into a single summary, lazily flushed by the next hook event, TUI poll, or quiet-off. - ring quiet [on | off | DURATION]: temporary global mute; waiting notifications accumulate in the queue and a summary is flushed on release. Both default to off (0 / no quiet file) for backward compatibility. The queue's check-then-act is guarded by fcntl.flock so concurrent hook subprocesses cannot both claim the leading edge. Headless watch also triggers the lazy flush each poll so users with no TUI aren't stuck. --- docs/guide.en.md | 40 ++- docs/guide.md | 33 ++- src/ring/cli.py | 37 +++ src/ring/commands/quiet.py | 54 ++++ src/ring/config.py | 28 ++- src/ring/hook.py | 11 + src/ring/locale/en/LC_MESSAGES/ring.mo | Bin 22203 -> 25147 bytes src/ring/locale/en/LC_MESSAGES/ring.po | 83 +++++++ src/ring/locale/ring.pot | 298 ++++++++++++++-------- src/ring/notify/__init__.py | 62 ++++- src/ring/notify/base.py | 19 +- src/ring/notify_queue.py | 332 +++++++++++++++++++++++++ src/ring/registry.py | 4 + src/ring/tui.py | 43 +++- tests/test_cli.py | 116 +++++++++ tests/test_config.py | 43 ++++ tests/test_hook.py | 41 ++- tests/test_notify.py | 181 +++++++++++++- tests/test_notify_queue.py | 327 ++++++++++++++++++++++++ tests/test_tui.py | 101 ++++++++ 20 files changed, 1737 insertions(+), 116 deletions(-) create mode 100644 src/ring/commands/quiet.py create mode 100644 src/ring/notify_queue.py create mode 100644 tests/test_notify_queue.py diff --git a/docs/guide.en.md b/docs/guide.en.md index 49f19bd..132be7f 100644 --- a/docs/guide.en.md +++ b/docs/guide.en.md @@ -16,6 +16,8 @@ Start with the [documentation home page](index.md) for the product overview, ins | `ring focus SESSION_ID` | Focus a specific session; unique prefixes work | | `ring config` | Show config path and effective settings | | `ring config set KEY VALUE` | Write one config value | +| `ring quiet` | Show temporary global mute (quiet) status | +| `ring quiet on` / `off` / `30m` | Turn quiet on/off, or on for a while (auto-clears) | | `ring doctor` | Read-only environment diagnosis | | `ring digest --since 4h` | Away summary: recent session state and wait stats | | `ring gc --dry-run` | Preview RiNG-owned stale state cleanup | @@ -127,6 +129,38 @@ notify_also = ["ntfy"] # desktop notification as usu `notify_backend = "ntfy"` pushes to the phone only. For Slack / your own bot / IFTTT, set `notify_webhook_url` to use the generic webhook backend (JSON POST with a stable, additive-only payload). +### Notification Coalescing And Temporary Quiet (`notify_debounce_seconds` / `ring quiet`) + +When several sessions flip to 🔴 waiting at once, sending one notification per session becomes +spam. Two independent, stackable mechanisms address this: + +- **Coalescing (debounce)**: set `notify_debounce_seconds` (default `0`, off, backward compatible) + to a positive number (e.g. `5`) and only the first notification within that window is sent right + away (leading edge); the rest within the same window merge into a single "N more sessions are + waiting for you" summary. RiNG has no long-running daemon, so the summary is flushed lazily by + the next hook event, the TUI poll loop, or when quiet is turned off — never dropping the one + notification that already fired. +- **Temporary global mute (`ring quiet`)**: mute for a while; every notification during that window + is queued first, and a coalesced summary is sent once quiet is cleared. It shares the same queue + as debounce, but the two mechanisms are independent — quiet is always available regardless of + `notify_debounce_seconds`. + +```sh +ring quiet # show current status (on/off, remaining time) +ring quiet on # turn on; stays muted until manually cleared +ring quiet 30m # turn on for 30 minutes; auto-clears on expiry +ring quiet off # turn off, flushing any coalesced summary right away +``` + +```toml +# ~/.config/ring/config.toml +notify_debounce_seconds = 5 # 0 = off (default); positive = coalescing window in seconds +``` + +While the TUI is open, the header shows quiet status and the queue count (e.g. +`🔇 QUIET · 12m left | queue: 3`); it stays out of the way when quiet is off and the queue is +empty. Quiet is a global setting, not per-session. + ### Cleaning RiNG State Files (`ring gc`) When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the @@ -297,8 +331,9 @@ RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on ma on Linux. If none are available, RiNG keeps the board running and skips notifications. Config keys include `notify_sound`, `notify_sound_name`, `notify_ignore_dnd`, -`notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, and `waiting_cooldown_seconds`. -Custom notification channels can be added by registering another `Notifier`. +`notify_repeat_seconds`, `notify_repeat_max`, `notify_backend`, `waiting_cooldown_seconds`, and +`notify_debounce_seconds`. Custom notification channels can be added by registering another +`Notifier`. Background subagent permission requests can make a session flap between waiting and working in quick succession. `waiting_cooldown_seconds` (180s by default) stops `ring hook`'s system @@ -342,6 +377,7 @@ notify_backend = "auto" # auto / terminal-notifier / osascript / notify notify_repeat_seconds = [30, 120, 300] notify_repeat_max = 3 waiting_cooldown_seconds = 180 # suppress an immediate re-alert on re-entering waiting within this window; 0 = off +notify_debounce_seconds = 0 # cross-session notification coalescing window, in seconds; 0 = off (default) notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic) notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST) notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"] diff --git a/docs/guide.md b/docs/guide.md index c053071..114e8c6 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -16,6 +16,8 @@ | `ring focus SESSION_ID` | 聚焦指定 session;可用唯一前綴 | | `ring config` | 顯示設定檔路徑與生效設定 | | `ring config set KEY VALUE` | 寫入單一設定 | +| `ring quiet` | 顯示暫時全域靜音(quiet)現況 | +| `ring quiet on` / `off` / `30m` | 開啟 / 解除 quiet,或開啟一段時間後自動解除 | | `ring doctor` | 唯讀環境診斷 | | `ring digest --since 4h` | 離席摘要:最近 session 狀態與等待統計 | | `ring gc --dry-run` | 預覽 RiNG 自己的 stale 狀態檔清理 | @@ -126,6 +128,33 @@ notify_also = ["ntfy"] # 桌面通知照發,再「 `notify_backend = "ntfy"` 則是只推手機、不發桌面通知。要接 Slack / 自家 bot / IFTTT, 用 `notify_webhook_url` 走通用 webhook 後端(JSON POST,欄位穩定只加不改)。 +### 通知合流與暫時靜音(`notify_debounce_seconds` / `ring quiet`) + +多個 session 同時轉 🔴 等你時,各自發一則系統通知會被轟炸。兩個獨立、可疊加的機制: + +- **通知合流(debounce)**:`notify_debounce_seconds`(預設 `0`,關閉、向後相容)設成正數 + (例如 `5`)後,n 秒內冒出的多則通知只有第一則立即發(leading edge),之後的合併成一則 + 「另有 N 個 session 在等你」彙總——RiNG 沒有常駐 daemon,彙總由下一個 hook 事件、TUI 輪詢 + 或 quiet 解除時**懶惰補發**,不會吞掉唯一的通知。 +- **暫時全域靜音(`ring quiet`)**:手動靜音一段時間,期間所有通知先進 queue,解除時一併 + 補發彙總;跟 debounce 共用同一個 queue,但兩者互相獨立——quiet 不受 + `notify_debounce_seconds` 影響,永遠可用。 + +```sh +ring quiet # 顯示目前現況(開/關、剩餘時間) +ring quiet on # 開啟,手動解除前一直靜音 +ring quiet 30m # 開啟 30 分鐘,到期自動解除 +ring quiet off # 解除,立即補發合流中的彙總通知 +``` + +```toml +# ~/.config/ring/config.toml +notify_debounce_seconds = 5 # 0 = 關閉(預設);正數=合流窗口秒數 +``` + +開著 TUI 時,header 會顯示 quiet 狀態與排隊計數(例如 `🔇 QUIET · 剩 12 分 | queue: 3`), +沒有靜音、queue 也是空的時候不佔版面。quiet 是全域粒度,不分特定 session。 + ### 清理 RiNG 狀態檔(`ring gc`) RiNG 正常收到 `SessionEnd` 時會刪掉自己的 hook registry;如果 agent crash 或 hook 沒跑到結尾, @@ -305,7 +334,8 @@ RiNG 會優先相信這些欄位;沒有時才退回 event / notification type 退回 `osascript`(macOS)或 `notify-send`(Linux)。如果全部不可用,就只保留看板,不讓通知失敗打斷主流程。 - **設定**:`notify_sound`、`notify_sound_name`、`notify_ignore_dnd`、`notify_repeat_seconds`、 - `notify_repeat_max`、`notify_backend`、`waiting_cooldown_seconds` 都可在 `~/.config/ring/config.toml` 調整。 + `notify_repeat_max`、`notify_backend`、`waiting_cooldown_seconds`、`notify_debounce_seconds` + 都可在 `~/.config/ring/config.toml` 調整。 - **防翻轉轟炸**:背景 subagent 的權限請求可能讓 session 在等你/工作中之間快速翻轉。 `waiting_cooldown_seconds`(預設 180 秒)讓 `ring hook` 的系統通知與 TUI 的響鈴/提醒, 在 session 離開等你又很快轉回時,距上次通知未滿冷卻期就不再立即發;設 `0` 關閉冷卻 @@ -357,6 +387,7 @@ notify_backend = "auto" # auto / terminal-notifier / osascript / notify notify_repeat_seconds = [30, 120, 300] # 持續等你時,幾秒後重複提醒 notify_repeat_max = 3 # 重複提醒上限;0 = 不限 waiting_cooldown_seconds = 180 # 離開等你又轉回時,距上次提醒未滿這段時間就不再立即提醒;0 = 關閉 +notify_debounce_seconds = 0 # 跨 session 通知合流窗口秒數;0 = 關閉(預設,向後相容) notify_ntfy_url = "" # 設完整 ntfy topic URL 啟用手機推播(如 https://ntfy.sh/my-topic) notify_webhook_url = "" # 設 URL 啟用通用 webhook 後端(JSON POST) notify_also = [] # 主後端之外「加發」的後端,如 ["ntfy"](桌面+手機各一份) diff --git a/src/ring/cli.py b/src/ring/cli.py index 7483c11..9f267f5 100644 --- a/src/ring/cli.py +++ b/src/ring/cli.py @@ -25,6 +25,7 @@ from ring.commands.focus import run_focus from ring.commands.gc import run_gc from ring.commands.hook import run_hook_command, run_install_hooks, run_remove_hooks +from ring.commands.quiet import run_quiet from ring.commands.stats import run_stats from ring.config import CONFIG_PATH as CONFIG_PATH from ring.config import Config as Config @@ -34,6 +35,7 @@ from ring.i18n import gettext as _ from ring.i18n import ngettext, set_lang from ring.labels import load_labels +from ring.notify_queue import flush_if_due from ring.plugins import load_plugins from ring.registry import Session, Status, running_agent_pids from ring.sources import discover_sessions @@ -399,16 +401,32 @@ def run_config(args: list[str]) -> int: return 2 +def _watch_flush_if_due() -> None: + """headless watch 輪詢時的懶惰 flush 觸發點(同 hook.py / tui.py 既有呼叫慣例)。 + + quiet active 或仍在 debounce 視窗內時,``flush_if_due`` 內部本來就會 skip(跟 + ``test_run_hook_does_not_flush_while_quiet_active`` 同機制),這裡不需另外判斷。 + 失敗安靜吞掉,不影響看板本身。 + """ + try: + flush_if_due() + except Exception: + pass + + def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int: # 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now); # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。 # 例外:codex 核可等待是讀取側的靜默逾時判定,沒有 hook 事件可發通知,由 TUI 的 # 提醒排程器代發(tui._ring_on_waiting_alerts);headless watch 仍不發,是已知限制。 + # debounce/quiet 合流 queue 的懶惰 flush:headless watch 沒有 hook 事件也沒有 TUI 輪詢 + # 可以觸發,每輪自己補上一次,否則純 headless 使用者開了 quiet/debounce 通知會無限期卡住。 frames = 0 footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval)) if not HAVE_RICH: try: while True: + _watch_flush_if_due() sys.stdout.write("\033[2J\033[H") sessions = board(show_all) print(_render_plain(sessions, show_legend, show_tool_column(sessions))) @@ -425,6 +443,7 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int try: with Live(console=console, screen=True, auto_refresh=False) as live: while True: + _watch_flush_if_due() sessions = board(show_all) body = _rich_renderable(sessions, show_legend, show_tool_column(sessions)) live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True) @@ -457,6 +476,10 @@ def _commands_help() -> str: config 顯示設定檔位置與目前生效的設定 config get KEY 讀單一設定的目前值 config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解) + quiet 顯示 quiet(暫時全域靜音)現況 + quiet on 開啟 quiet(手動解除前一直靜音) + quiet off 解除 quiet(立即補發合流的彙總通知) + quiet DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除 focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列 gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔 doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示 @@ -493,6 +516,17 @@ def _subcommand_help(name: str) -> str: 不帶參數:顯示設定檔位置(~/.config/ring/config.toml)與目前生效的所有設定。 get KEY 印出單一設定的目前值(colors 子鍵用 colors.)。 set KEY VALUE 寫入單一設定。注意:會重寫整個設定檔,原有註解不會保留。 +""" + ), + "quiet": _( + """usage: ring quiet [on | off | DURATION] + +暫時全域靜音:靜音期間所有轉 🔴 等你的通知都先進 queue,解除時懶惰補發一則彙總。 + +不帶參數:顯示目前 quiet 現況(開/關、剩餘時間)。 + on 開啟 quiet,手動解除前一直靜音。 + off 解除 quiet,並立即補發合流的彙總通知。 + DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除。 """ ), "focus": _( @@ -569,6 +603,7 @@ def main(argv: list[str] | None = None) -> int: "digest", "stats", "completion", + "quiet", } and any(arg in {"-h", "--help"} for arg in raw[1:]) ): @@ -583,6 +618,8 @@ def main(argv: list[str] | None = None) -> int: return run_remove_hooks(raw[1:]) if raw and raw[0] == "config": return run_config(raw[1:]) + if raw and raw[0] == "quiet": + return run_quiet(raw[1:]) if raw and raw[0] == "gc": return run_gc(raw[1:]) if raw and raw[0] == "doctor": diff --git a/src/ring/commands/quiet.py b/src/ring/commands/quiet.py new file mode 100644 index 0000000..fe60875 --- /dev/null +++ b/src/ring/commands/quiet.py @@ -0,0 +1,54 @@ +"""``ring quiet`` command handler:暫時全域靜音(跟 debounce 共用同一份 queue/flush 機制)。""" + +from __future__ import annotations + +import sys +import time + +from ring.commands._args import strip_lang +from ring.gc import parse_duration +from ring.i18n import gettext as _ +from ring.notify_queue import clear_quiet, flush_if_due, format_remaining, quiet_active, quiet_remaining, set_quiet + + +def _print_status(now: float) -> None: + if not quiet_active(now): + print(_("🔈 quiet:目前關閉")) + return + remaining = quiet_remaining(now) + if remaining is None: + print(_("🔇 quiet:開啟中(手動解除前一直靜音)")) + else: + print(_("🔇 quiet:開啟中,剩 {remaining}", remaining=format_remaining(remaining))) + + +def run_quiet(args: list[str]) -> int: + """不帶參數→顯示現況;``on``→無限靜音;``off``→解除並立即 flush;````→限時靜音。""" + args = strip_lang(args) + now = time.time() + + if not args: + _print_status(now) + return 0 + + action = args[0] + if action == "on": + set_quiet(None) + print(_("🔇 quiet 已開啟(手動解除前一直靜音)")) + return 0 + + if action == "off": + clear_quiet() + flush_if_due(force=True) + print(_("🔈 quiet 已解除")) + return 0 + + try: + seconds = parse_duration(action) + except ValueError: + print(_("無效的 duration:{value}(例如 30m、1h)", value=action), file=sys.stderr) + return 2 + + set_quiet(now + seconds) + print(_("🔇 quiet 已開啟,{duration} 後自動解除", duration=action)) + return 0 diff --git a/src/ring/config.py b/src/ring/config.py index 3b0bf22..dd13cd7 100644 --- a/src/ring/config.py +++ b/src/ring/config.py @@ -27,6 +27,10 @@ notify_repeat_max = 3 # 重複提醒上限;0 = 不限 waiting_cooldown_seconds = 180 # session 離開 WAITING 又轉回時,距上次提醒未滿這段時間就 # 不再當「新轉入」立即提醒(防翻轉轟炸);0 = 關閉(現行為) + notify_debounce_seconds = 0 # 多個 session 同時轉 🔴 等你時的通知合流窗口(秒):窗口內 + # 第一批立即發(leading edge),之後的合併成一則彙總,由下個 + # hook 事件 / TUI 輪詢 / quiet 解除懶惰 flush;0 = 關閉(現行為, + # 向後相容,每次轉入都各自發) notify_ntfy_url = "https://ntfy.sh/my-topic" # 設了才啟用 ntfy 後端(推到手機) notify_webhook_url = "https://example.com/hook" # 設了才啟用 webhook 後端(JSON POST) notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份) @@ -40,12 +44,15 @@ from __future__ import annotations +import sys import tomllib from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from functools import lru_cache from pathlib import Path +from ring.i18n import gettext as _ + CONFIG_PATH = Path.home() / ".config" / "ring" / "config.toml" # Rich 樣式字串。深淺底都看得到(避開 dim / ANSI blue)。config 的 [colors] 可逐項覆寫。 @@ -88,6 +95,7 @@ class Config: notify_repeat_seconds: tuple[int, ...] = (30, 120, 300) notify_repeat_max: int = 3 # 0 = 不限 waiting_cooldown_seconds: int = 180 # 離開 WAITING 又轉回時的提醒冷卻期;0 = 關閉(現行為) + notify_debounce_seconds: int = 0 # 跨 session 通知合流窗口(秒);0 = 關閉(現行為,向後相容) notify_ntfy_url: str = "" # 完整 ntfy topic URL;空=ntfy 後端不可用 notify_webhook_url: str = "" # webhook URL;空=webhook 後端不可用 notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"]) @@ -97,6 +105,21 @@ class Config: colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS)) +# config.toml 頂層合法鍵(含巢狀 table 名,如 "colors")——都是 Config dataclass 的欄位名。 +_KNOWN_KEYS = {f.name for f in fields(Config)} + + +def _warn_unknown_keys(raw: dict[str, object], path: Path) -> None: + """未知的頂層鍵(多半是打錯字)→ 印一次警告到 stderr;只警告,絕不 raise、不影響載入。""" + unknown = sorted(k for k in raw if k not in _KNOWN_KEYS) + if not unknown: + return + print( + _("⚠️ {path} 有未知的設定鍵(可能打錯字,已忽略):{keys}", path=path, keys=", ".join(unknown)), + file=sys.stderr, + ) + + def _as_int(v: object, default: int) -> int: return v if isinstance(v, int) and not isinstance(v, bool) else default @@ -137,6 +160,7 @@ def load(path: Path | None = None) -> Config: raw = tomllib.loads(p.read_text()) except (OSError, tomllib.TOMLDecodeError): return Config() + _warn_unknown_keys(raw, p) d = Config() lang = raw.get("lang") # 任意非空字串都接受(後端名稱由 notify 層的可插拔 registry 決定);認不得的名稱 @@ -163,6 +187,7 @@ def load(path: Path | None = None) -> Config: notify_repeat_seconds=_as_positive_int_tuple(raw.get("notify_repeat_seconds"), d.notify_repeat_seconds), notify_repeat_max=max(0, _as_int(raw.get("notify_repeat_max"), d.notify_repeat_max)), waiting_cooldown_seconds=max(0, _as_int(raw.get("waiting_cooldown_seconds"), d.waiting_cooldown_seconds)), + notify_debounce_seconds=max(0, _as_int(raw.get("notify_debounce_seconds"), d.notify_debounce_seconds)), notify_backend=notify_backend, notify_ntfy_url=(raw["notify_ntfy_url"] if isinstance(raw.get("notify_ntfy_url"), str) else ""), notify_webhook_url=(raw["notify_webhook_url"] if isinstance(raw.get("notify_webhook_url"), str) else ""), @@ -237,6 +262,7 @@ def _coerce_str_list(s: str) -> list[str]: "notify_repeat_seconds": _coerce_int_list, "notify_repeat_max": _coerce_int, "waiting_cooldown_seconds": _coerce_int, + "notify_debounce_seconds": _coerce_int, "notify_ntfy_url": str, "notify_webhook_url": str, "notify_also": _coerce_str_list, diff --git a/src/ring/hook.py b/src/ring/hook.py index 04e8d7c..7e2db19 100644 --- a/src/ring/hook.py +++ b/src/ring/hook.py @@ -154,7 +154,18 @@ def run_hook(provider: str = "claude-code") -> int: ``agent-hooks`` 時發生:把原始 payload 透傳給 ``agent-hooks callback``,由它同步出 modal、 收按鈕、把決策寫到 stdout 回給 Claude(這條路下 ``_ring_waiting_now`` 自動短路、不重複發)。 其餘情況 RiNG 記狀態 + 發通知後 exit 0(你在終端自己回答)。 + + 每次執行開頭先嘗試懶惰 flush 合流 queue(見 ``notify_queue.flush_if_due``)——沒有常駐 + daemon,任何 hook 事件(不限於這次自己是不是 waiting 事件)都是「debounce 視窗過期」的 + 天然觸發點,讓 headless(沒開 TUI)也能補發彙總通知。失敗安靜吞掉,不影響本次事件記錄。 """ + try: + from ring.notify_queue import flush_if_due + + flush_if_due() + except Exception: + pass + try: raw = sys.stdin.read() except OSError: diff --git a/src/ring/locale/en/LC_MESSAGES/ring.mo b/src/ring/locale/en/LC_MESSAGES/ring.mo index 4e26bf34aca4df07533c6f78ddff405138497f0f..0fb6ee7889a2c80244472c3ec2032650759a1a54 100644 GIT binary patch delta 5869 zcmbW(3vg7`8Nl%qLWnM}fM5U>4v3ma2nh&kL=X!Uih%O4R6$(H-Rzcq;6A{R#^s&F z1XGQm#Aw7|KmkQpYt#g*RV!$xbYvWGS_?yVlPyytYHb-E?f=WY2`h%uHe>kRbM8H_ z?|kRPH})pI@lI0s)BfGJD1P4M_fmeR_K?zn0;&I5g_c{mdDaX6OY5ZrCOe-HamK4RH| zGLf&X_q_&~0}VwF^`p>++wf-GfnjXH+me(jpz-%o^BU9qgcq-+E9J*W1}XI*AEdI3 z`zdc_7PD~RB}(n5-i}Vn`ZA>!Q!c~nD945>#p2X3!Xg8Y#Z43w^w%E4tQk$D8CVm;o9CvhT99B!_x5T)FRi|{ng!FeN;x)QgeOyqsM z1`pxQm~uH0k%t*PNT|zkG}fc+>0um;-{G~G%l^zp7fSh6l=f{X6Bx_J%EWyr?VqyB z2k}D6-=O4J3bSIZYQ&YqpSJ2|tD*phH4*TW-QByx)wHGk-*xQ1mL|FCDc}Ap?DFnVezPry*4;9W_oy`R;D(y$5fn97N`( zK0xXC3(Id&`tLzFq`o&wIo&D`4ONHA6mt4&+jzBj~ zLYd%h9FGlF`7@O7zd?=NMk&SIRQPfpWCgb%CqvbteAr<5CQ1a}LmB8ZT!OYN^E)R> zM?RF?c?{>{dpH?~P^s|_l=elKi;p7Xgw+8aR#R~ZWo5In%_Q+4iK-qznb=jYM`9I0SY${UtT3*b@n{Wxr3Z6t6 z=u?!APojMH1>S|O^#U5sp>nvebQev|8P*?;po`_&SD{^3a=1 zv7raK%+!4-$+MMzn56m;<-C7~vey%c=Kx%aax8-==lVh1m_+iSFNL;Ti!-U;exp)p zcoHQd-(n^XokaX4xn{6j3C#+W4=b<_K7o9z_Mm+CH|&S~*%x^~5~ag&DEG!fl$=?O z5{V~J`fI?xc)+SZioGd+H92hVRkxeWBirMy3lYbmdruGG)4I~#q1`nmW^%Hs&fRLW1{)s#&7`@zW>`U&M6$GxDGMihpFQZo0)>u^*-VdUW7hI0G}tbh%gVMY&bTk}gLK#;5Sfr`S zL*}3=Q5JFnHO4U+ZMU1JqYs7)smSC(LRN>eml2eS97k#Jca%_{!YeWD=jK4yVt>jN zCbY?J1QjP*-UdhPC#kk zMv25Wls$X~C7Jdii&1Y`?*}h7|Cj3uoJf5TWouqU*}69@e~Zjny^BL|Sa^xK(mWhM z#Uhjyd+-9>gmR-*A}3Y7gxgU5@<_+kmbG{T+{QQZphG@Dj>1aUg~b z>!Aoa;_4xk<8%ziVz;}^y&sR9K(zqrQ`MtHB*kvNPs8gek3nhY!f{xG8V{iicnW0; zQtxS>V2VoTq3ZM8QIV3Kr;>Wy8*&*zT~*!dvaoJ{Zfh)!Rqu{%TNmB9D_UC{f1)N{ z8)<7SYpL8Hi>$S2^DFK1>aKp`Tg#(cYR~jkRvs%QzHLXeY;$wNnwH03?(FhR zXAXzf;UD*BHtg)JWp{aWbELJdre#}W^w*`aSJt*vZ-_Q+YiVeTuc>aSeTHdv_BnOo zyeSK2&Yg2suZa`X%qRBzE37rXwW=+%v9x)Ad9;3=9+%@ev1V6t=ZMGx2(D8sjAui4%_Yx86hK27kP^#y8po* zVKc^e60%=uA4~m}?wH&Xo2{#0SDPVXSSA)LTf-o&O=bG?+DBg2<~S_eTylJUQ*>i# ze9cQliBXxa88j9ZEBQ@q{l3<^izHqwHCk3;2J6I{byaB>rH5M{f1XXTHq_i-cAp51 zi9cFH1fpfn#&gO3$5#$Vr5nFiWj1LAC=sB^`+>WO)Xm_ zGus-giE+X5RM=DzRpFfBkA8{}xXT$c`mQrS+ z^Fw}nkRD`^m3DW?C}uuCPUpz88M{)$CHl67Gp8+3(Cd}R(r@3M8|1#7M+f{V2l2R+nSxM`# z_UlN7wQ6Z@9tF}qzO}4scFw8`#^&hU@pp;%+B?YCA#cz{XLfJM?sgaI zWp2anH}W$(dko73(#20=mwb`oaEAg;BcSbGI`P@vMqrtduLB{EN9OEz6*z<1zRF&B zrsu?Cl`ueJ$6oEo@%gn~uX4KFhR!gu3$o?5%+k5e%q(pW1%0F2XB?1g!nkjR%U@U( z>a8P}UvnVYMveu19>eD~A|G9wl$3Z)estaM&vQ*K=qlcM)jU)#f7#ECD>K;~<=eYJ}X z@p+dS*4FXT+MaBiZH`ad{RM$M9dP7dil%R+v?;SE|Jn~9+_eBLqaV3zJYXgcWf z7@65N{{AIq%MZeSgXGnDQiLAb|G0E)U1A3^pscZdE$2#!MV&bzT_t8iVppWVIWypf zt}`G~KKUO>7dVTZai@o^6Yon^2})q y+x;xX5j6bz%q^SHChz|kN7`g1{x#@aLdQiaIU!4MIMQS+tD1HWN7dHbulP6d(I3eG delta 3077 zcmYM$du&f<9LMqRuhmwo+PW*c{@PNvDlMv0T9>v~rPTd?o6KBFLc+R<-)1gLB(^G( zhAl=XR<^~?kfm-b8&-ztKSoJ5t6OX?!6d`ppPiFTw6Ev;oO7P@eZJ3g9;#3Js=xFF zt~GD8#rX3rKOOiv9HQv|Khv6+wQpoL9dqa|jW%nA-(ftS$FBGnreJIgKbViLaTZ46 z3e3he*a=S{Lk8?Njb?N_#*P?9kbo(u{vv)w;VjQ3s03Cae{D0r^n%@3f({PBgytIE zY%Ui2%x>UI;*6GNze)yKsymvI=0Et@1Mntl1|8$vcpgq7uEZjI zj>$OU6|+9L40Zn=&$~!YmKyISQh-{b6_}2nVLzWDHw@TDq-XeAtVxqAKG{beAX#wKrb#;yH=bKa`Fobf|R8F(22V`pE7uFXSVKHhqAH}{{>%~5@ z(*sfH$5z+{Gf)$liEL~;h@n{P`3tH7mr?KW5lqK8)O~?>XlS6-s9n1k$Kqwo!5&=n zV+m@2ji?8!@NL|Wno&wucTW@{t6~dL32j97qwPZNsT$M-A0cz2{@vUIAsIFB2-J;Z zF$33l{nZ#wT!Y#~XK^Gpc>R64n++n)MkO9Vz33Qf+_R|XFX0TV$98)EfD|{wiKqvb zqh|Cz>cP)ZGv9~9@HB417|NyVyF9Bfn|Qw$-$#8-Pmvt0PpW%v%tTdWDQf>&84Z1} z2XGu-&;{(&)BRoxP!FucF}Mw9;-9F`u!wT4^-*%{(8%mTnppw;QIszRC!r=ZAE~-+ zKvvfdU_djgr=c64VH8GiC=A3{)QuBSCzcKiT`xloxC7bPR)d8KS`;#qPw+bN?fzy@@Jtqm3?8SRA8miLUGv;MG6i*iMSzB0d7J^`2#@(r-f&3|JM7MsyrR zzAURo&G;5ZU>g=#11I7r?25Xt6t$Ulqh{)$5;^913N`L|FRnw4a}~q!_y77?|9dp_ z**!*`e8UUf8GMR<;z|t0A5gpb43^>zRHbH&aF<{)HX+`Qx_=L<#QQKAYf}Q%{UdmLX|Y4$X&Y_)O~%BoNYF0bM8Z}^&M=BjVYJLO+Y2w z1>pbPufB`*0d%=QEN8Ra|})ed8Lyi>77)Sk8+NE^pvv>=;<92M0r%@;56>No1J)4eqXOM*Y zdX{23Zbq&7DP)q?fHN?2f;;|Jw20T2l$6X~Ja<|D2!Hv&%vYSPkq><36I z<+Uw-b}Cz*2`$elXy@d|-Sav5@vDQK3yC{?&e4t+gPe< for color sub-keys).\n" " set KEY VALUE write one setting. Note: rewrites the entire config file; existing comments are lost.\n" +msgid "" +"usage: ring quiet [on | off | DURATION]\n" +"\n" +"暫時全域靜音:靜音期間所有轉 🔴 等你的通知都先進 queue,解除時懶惰補發一則彙總。\n" +"\n" +"不帶參數:顯示目前 quiet 現況(開/關、剩餘時間)。\n" +" on 開啟 quiet,手動解除前一直靜音。\n" +" off 解除 quiet,並立即補發合流的彙總通知。\n" +" DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除。\n" +msgstr "" +"usage: ring quiet [on | off | DURATION]\n" +"\n" +"Temporary global mute: while quiet, every 🔴 waiting-for-you notification is queued first,\n" +"and a coalesced summary is flushed lazily once quiet is cleared.\n" +"\n" +"No args: show the current quiet status (on/off, remaining time).\n" +" on turn quiet on; stays muted until manually cleared.\n" +" off turn quiet off, and flush any coalesced summary right away.\n" +" DURATION turn quiet on for a while (e.g. 30m, 1h); auto-clears on expiry.\n" + msgid "" "usage: ring focus SESSION_ID\n" "\n" @@ -695,3 +726,55 @@ msgstr "{project}: the dialog was gone and the digit landed in the input box — msgid "{project}:已送出但拿不到畫面驗證,請跳過去確認" msgstr "{project}: key sent but the screen could not be re-read to verify — jump over to check" + +# ── notify_queue.py:debounce queue / quiet mode ── +msgid "{s} 秒" +msgstr "{s}s" + +msgid "{m} 分" +msgstr "{m}m" + +msgid "{h} 小時" +msgstr "{h}h" + +# ── notify/__init__.py:合流彙總通知 ── +msgid "另有 {n} 個 session 在等你" +msgid_plural "另有 {n} 個 session 在等你" +msgstr[0] "{n} more session is waiting for you" +msgstr[1] "{n} more sessions are waiting for you" + +# ── notify/base.py:彙總通知標題 ── +msgid "RiNG · 還有人在等你" +msgstr "RiNG · someone is waiting for you" + +# ── tui.py:quiet / queue header badge ── +msgid "🔇 QUIET" +msgstr "🔇 QUIET" + +msgid "🔇 QUIET · 剩 {remaining}" +msgstr "🔇 QUIET · {remaining} left" + +msgid "queue: {n}" +msgstr "queue: {n}" + +# ── commands/quiet.py:ring quiet CLI ── +msgid "🔈 quiet:目前關閉" +msgstr "🔈 quiet: off" + +msgid "🔇 quiet:開啟中(手動解除前一直靜音)" +msgstr "🔇 quiet: on (stays muted until manually cleared)" + +msgid "🔇 quiet:開啟中,剩 {remaining}" +msgstr "🔇 quiet: on, {remaining} left" + +msgid "🔇 quiet 已開啟(手動解除前一直靜音)" +msgstr "🔇 quiet turned on (stays muted until manually cleared)" + +msgid "🔈 quiet 已解除" +msgstr "🔈 quiet turned off" + +msgid "無效的 duration:{value}(例如 30m、1h)" +msgstr "invalid duration: {value} (e.g. 30m, 1h)" + +msgid "🔇 quiet 已開啟,{duration} 後自動解除" +msgstr "🔇 quiet turned on, auto-clears after {duration}" diff --git a/src/ring/locale/ring.pot b/src/ring/locale/ring.pot index 12e5a19..52ac6a9 100644 --- a/src/ring/locale/ring.pot +++ b/src/ring/locale/ring.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-07-12 09:22+0800\n" +"POT-Creation-Date: 2026-07-20 15:34+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,144 +17,144 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: src/ring/cli.py:127 +#: src/ring/cli.py:129 msgid "等你" msgstr "" -#: src/ring/cli.py:128 +#: src/ring/cli.py:130 msgid "工作中" msgstr "" -#: src/ring/cli.py:129 +#: src/ring/cli.py:131 msgid "跑完停著" msgstr "" -#: src/ring/cli.py:130 +#: src/ring/cli.py:132 msgid "已離場" msgstr "" -#: src/ring/cli.py:156 +#: src/ring/cli.py:158 #, python-brace-format msgid "{n} 個 session 在場" msgid_plural "{n} 個 session 在場" msgstr[0] "" msgstr[1] "" -#: src/ring/cli.py:157 +#: src/ring/cli.py:159 #, python-brace-format msgid "{n} 個 agent process 跑著" msgid_plural "{n} 個 agent process 跑著" msgstr[0] "" msgstr[1] "" -#: src/ring/cli.py:158 +#: src/ring/cli.py:160 #, python-brace-format msgid "🎤 RiNG — {sess} · {proc}" msgstr "" -#: src/ring/cli.py:163 src/ring/cli.py:216 src/ring/tui.py:279 +#: src/ring/cli.py:165 src/ring/cli.py:218 src/ring/tui.py:283 msgid "圖例" msgstr "" -#: src/ring/cli.py:180 src/ring/cli.py:218 +#: src/ring/cli.py:182 src/ring/cli.py:220 msgid "(場館目前沒人上台)" msgstr "" -#: src/ring/cli.py:184 src/ring/cli.py:338 src/ring/commands/doctor.py:31 +#: src/ring/cli.py:186 src/ring/cli.py:340 src/ring/commands/doctor.py:31 #: src/ring/commands/doctor.py:68 src/ring/commands/doctor.py:70 #: src/ring/commands/doctor.py:72 src/ring/commands/doctor.py:134 -#: src/ring/tui.py:234 +#: src/ring/tui.py:238 msgid "狀態" msgstr "" -#: src/ring/cli.py:186 src/ring/cli.py:235 src/ring/tui.py:236 +#: src/ring/cli.py:188 src/ring/cli.py:237 src/ring/tui.py:240 msgid "工具" msgstr "" -#: src/ring/cli.py:187 src/ring/cli.py:235 src/ring/commands/stats.py:60 -#: src/ring/tui.py:237 +#: src/ring/cli.py:189 src/ring/cli.py:237 src/ring/commands/stats.py:60 +#: src/ring/tui.py:241 msgid "專案" msgstr "" -#: src/ring/cli.py:188 src/ring/cli.py:235 src/ring/tui.py:237 +#: src/ring/cli.py:190 src/ring/cli.py:237 src/ring/tui.py:241 msgid "進度" msgstr "" -#: src/ring/cli.py:189 src/ring/cli.py:235 src/ring/tui.py:237 +#: src/ring/cli.py:191 src/ring/cli.py:237 src/ring/tui.py:241 msgid "閒置" msgstr "" -#: src/ring/cli.py:190 src/ring/cli.py:235 src/ring/tui.py:237 +#: src/ring/cli.py:192 src/ring/cli.py:237 src/ring/tui.py:241 msgid "去哪" msgstr "" -#: src/ring/cli.py:192 src/ring/cli.py:235 src/ring/tui.py:237 +#: src/ring/cli.py:194 src/ring/cli.py:237 src/ring/tui.py:241 msgid "動作" msgstr "" -#: src/ring/cli.py:320 +#: src/ring/cli.py:322 msgid "(內建預設)" msgstr "" -#: src/ring/cli.py:336 +#: src/ring/cli.py:338 msgid "RiNG 設定檔" msgstr "" -#: src/ring/cli.py:337 src/ring/commands/doctor.py:133 +#: src/ring/cli.py:339 src/ring/commands/doctor.py:133 msgid "路徑" msgstr "" -#: src/ring/cli.py:338 src/ring/commands/doctor.py:134 +#: src/ring/cli.py:340 src/ring/commands/doctor.py:134 msgid "已存在" msgstr "" -#: src/ring/cli.py:338 src/ring/commands/doctor.py:134 +#: src/ring/cli.py:340 src/ring/commands/doctor.py:134 msgid "不存在(全部用內建預設)" msgstr "" -#: src/ring/cli.py:340 +#: src/ring/cli.py:342 msgid "目前生效的設定(← = 你覆寫過的)" msgstr "" -#: src/ring/cli.py:348 +#: src/ring/cli.py:350 msgid "" "用 `ring config set KEY VALUE` 改,或直接編輯上面那個檔;完整選項見 src/ring/config.py 的 " "docstring。" msgstr "" -#: src/ring/cli.py:359 src/ring/cli.py:362 +#: src/ring/cli.py:361 src/ring/cli.py:364 #, python-brace-format msgid "未知的鍵:{key}" msgstr "" -#: src/ring/cli.py:375 +#: src/ring/cli.py:377 msgid "用法:ring config get KEY" msgstr "" -#: src/ring/cli.py:386 +#: src/ring/cli.py:388 msgid "用法:ring config set KEY VALUE" msgstr "" -#: src/ring/cli.py:394 +#: src/ring/cli.py:396 #, python-brace-format msgid "✅ 已設定 {key} = {value}({path})" msgstr "" -#: src/ring/cli.py:395 +#: src/ring/cli.py:397 msgid "註:set 會重寫整個設定檔,原有註解不會保留。" msgstr "" -#: src/ring/cli.py:398 +#: src/ring/cli.py:400 #, python-brace-format msgid "未知的 config 動作:{action}(用 get / set,或不帶參數看目前設定)" msgstr "" -#: src/ring/cli.py:406 +#: src/ring/cli.py:425 #, python-brace-format msgid "每 {interval}s 刷新 · Ctrl-C 離場" msgstr "" -#: src/ring/cli.py:449 +#: src/ring/cli.py:470 msgid "" "\n" "commands:\n" @@ -166,6 +166,10 @@ msgid "" " config 顯示設定檔位置與目前生效的設定\n" " config get KEY 讀單一設定的目前值\n" " config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)\n" +" quiet 顯示 quiet(暫時全域靜音)現況\n" +" quiet on 開啟 quiet(手動解除前一直靜音)\n" +" quiet off 解除 quiet(立即補發合流的彙總通知)\n" +" quiet DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除\n" " focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列\n" " gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔\n" " doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示\n" @@ -174,28 +178,28 @@ msgid "" " completion SHELL 印出 shell 補全腳本(zsh / bash)\n" msgstr "" -#: src/ring/cli.py:471 +#: src/ring/cli.py:496 msgid "" "usage: ring hook [PROVIDER] [--provider PROVIDER]\n" "\n" "從 stdin 讀 hook JSON,依 provider 正規化後寫入 RiNG registry。\n" msgstr "" -#: src/ring/cli.py:477 +#: src/ring/cli.py:502 msgid "" "usage: ring install-hooks [--dry-run]\n" "\n" "安裝 Claude Code / Codex hooks。\n" msgstr "" -#: src/ring/cli.py:483 +#: src/ring/cli.py:508 msgid "" "usage: ring remove-hooks [--dry-run]\n" "\n" "從 Claude Code / Codex hook 設定移除 RiNG 安裝的 hooks。\n" msgstr "" -#: src/ring/cli.py:489 +#: src/ring/cli.py:514 msgid "" "usage: ring config [get KEY | set KEY VALUE]\n" "\n" @@ -204,14 +208,26 @@ msgid "" " set KEY VALUE 寫入單一設定。注意:會重寫整個設定檔,原有註解不會保留。\n" msgstr "" -#: src/ring/cli.py:497 +#: src/ring/cli.py:522 +msgid "" +"usage: ring quiet [on | off | DURATION]\n" +"\n" +"暫時全域靜音:靜音期間所有轉 🔴 等你的通知都先進 queue,解除時懶惰補發一則彙總。\n" +"\n" +"不帶參數:顯示目前 quiet 現況(開/關、剩餘時間)。\n" +" on 開啟 quiet,手動解除前一直靜音。\n" +" off 解除 quiet,並立即補發合流的彙總通知。\n" +" DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除。\n" +msgstr "" + +#: src/ring/cli.py:533 msgid "" "usage: ring focus SESSION_ID\n" "\n" "聚焦指定 session;若 RiNG TUI 正在執行,會回到 TUI 並選中該列。\n" msgstr "" -#: src/ring/cli.py:503 +#: src/ring/cli.py:539 msgid "" "usage: ring gc [--dry-run] [--older-than DURATION] [--all-ended]\n" "\n" @@ -223,7 +239,7 @@ msgid "" " --all-ended 清理所有目前判定已離場的 registry\n" msgstr "" -#: src/ring/cli.py:514 +#: src/ring/cli.py:550 msgid "" "usage: ring doctor\n" "\n" @@ -231,7 +247,7 @@ msgid "" "不寫任何檔案、不安裝、不發通知;固定回傳 0。\n" msgstr "" -#: src/ring/cli.py:521 +#: src/ring/cli.py:557 msgid "" "usage: ring digest [--since DURATION] [--format text|json]\n" "\n" @@ -242,7 +258,7 @@ msgid "" " --format text|json 輸出格式\n" msgstr "" -#: src/ring/cli.py:531 +#: src/ring/cli.py:567 msgid "" "usage: ring stats [--since DURATION]\n" "\n" @@ -253,268 +269,302 @@ msgid "" " --since DURATION 統計時間窗(例如 12h、7d、30d;預設 7d)\n" msgstr "" -#: src/ring/cli.py:541 +#: src/ring/cli.py:577 msgid "" "usage: ring completion zsh|bash\n" "\n" "印出 shell 補全腳本。zsh 放 ~/.zshrc:eval \"$(ring completion zsh)\";bash 同理。\n" msgstr "" -#: src/ring/cli.py:599 +#: src/ring/cli.py:638 msgid "看所有 agent CLI session 上台。" msgstr "" -#: src/ring/cli.py:604 +#: src/ring/cli.py:643 msgid "持續刷新" msgstr "" -#: src/ring/cli.py:605 +#: src/ring/cli.py:644 msgid "watch 刷新秒數" msgstr "" -#: src/ring/cli.py:606 +#: src/ring/cli.py:645 msgid "watch 刷新幾格後自動結束(0=無限,預設)" msgstr "" -#: src/ring/cli.py:607 +#: src/ring/cli.py:646 msgid "連已離場的 session 也顯示" msgstr "" -#: src/ring/cli.py:612 +#: src/ring/cli.py:651 msgid "顯示顏色圖例(--no-legend 關閉)" msgstr "" -#: src/ring/cli.py:614 +#: src/ring/cli.py:653 msgid "語言(如 en / zh-Hant;也吃 config / RING_LANG / LANG)" msgstr "" -#: src/ring/cli.py:619 +#: src/ring/cli.py:658 msgid "輸出格式:table(預設)/ json(機器可讀)/ oneline(status bar 單行摘要)" msgstr "" -#: src/ring/cli.py:625 +#: src/ring/cli.py:664 #, python-brace-format msgid "--format {fmt} 只能用在快照模式,不能配 --watch。" msgstr "" -#: src/ring/hook.py:74 +#: src/ring/config.py:118 +#, python-brace-format +msgid "⚠️ {path} 有未知的設定鍵(可能打錯字,已忽略):{keys}" +msgstr "" + +#: src/ring/hook.py:85 msgid "重開 Claude Code session 後,🔴 等你狀態就會精準起來。" msgstr "" -#: src/ring/hook.py:84 +#: src/ring/hook.py:95 msgid "重開 Codex session、並在它詢問時「信任」這個 hook,🔴 等你狀態才會精準起來(Codex 不會執行未信任的 hook)。" msgstr "" -#: src/ring/hook.py:438 +#: src/ring/hook.py:531 #, python-brace-format msgid "⚠️ 偵測到其他工具也掛在 {events}:{cmds}" msgstr "" -#: src/ring/hook.py:439 +#: src/ring/hook.py:532 msgid "它們會跟 RiNG 的通知重複觸發;要完全改用 RiNG,建議移除它們。" msgstr "" -#: src/ring/hook.py:487 src/ring/hook.py:563 +#: src/ring/hook.py:580 src/ring/hook.py:656 #, python-brace-format msgid "⚠️ {path} 不是合法 JSON,先處理它再來。" msgstr "" -#: src/ring/hook.py:525 +#: src/ring/hook.py:618 #, python-brace-format msgid "✅ RiNG hooks 已經裝過了,沒有變更。({path})" msgstr "" -#: src/ring/hook.py:538 +#: src/ring/hook.py:631 #, python-brace-format msgid "✅ 已註冊 RiNG hooks({events})到 {path}" msgstr "" -#: src/ring/hook.py:557 +#: src/ring/hook.py:650 #, python-brace-format msgid "ℹ️ {path} 不存在,沒有可移除的 hook。" msgstr "" -#: src/ring/hook.py:584 +#: src/ring/hook.py:677 #, python-brace-format msgid "ℹ️ 沒有找到 RiNG hook 條目,無需移除。({path})" msgstr "" -#: src/ring/hook.py:588 +#: src/ring/hook.py:681 #, python-brace-format msgid "✅ 已移除 RiNG hooks({events})從 {path}" msgstr "" +#: src/ring/notify_queue.py:250 +#, python-brace-format +msgid "{s} 秒" +msgstr "" + +#: src/ring/notify_queue.py:253 +#, python-brace-format +msgid "{m} 分" +msgstr "" + +#: src/ring/notify_queue.py:255 +#, python-brace-format +msgid "{h} 小時" +msgstr "" + #: src/ring/plugins.py:61 src/ring/plugins.py:68 #, python-brace-format msgid "⚠️ ring plugin '{name}' 載入失敗:{error}" msgstr "" -#: src/ring/tui.py:51 +#: src/ring/tui.py:52 #, python-brace-format msgid "背景 agent,沒有終端可跳;用 `claude --resume {session_id}` 接回" msgstr "" -#: src/ring/tui.py:96 +#: src/ring/tui.py:97 #, python-brace-format msgid "為 {project} 命名(Enter 存、Esc 取消、清空移除)" msgstr "" -#: src/ring/tui.py:97 +#: src/ring/tui.py:98 msgid "這個 session 在做什麼…" msgstr "" -#: src/ring/tui.py:141 +#: src/ring/tui.py:142 #, python-brace-format msgid "回覆 {project} 的權限請求" msgstr "" -#: src/ring/tui.py:146 +#: src/ring/tui.py:147 msgid "Enter 或數字鍵送出、Esc 取消" msgstr "" -#: src/ring/tui.py:169 +#: src/ring/tui.py:170 msgid "離場" msgstr "" -#: src/ring/tui.py:170 +#: src/ring/tui.py:171 msgid "刷新" msgstr "" -#: src/ring/tui.py:171 +#: src/ring/tui.py:172 msgid "含已離場" msgstr "" -#: src/ring/tui.py:172 +#: src/ring/tui.py:173 msgid "命名" msgstr "" -#: src/ring/tui.py:173 +#: src/ring/tui.py:174 msgid "隱藏" msgstr "" -#: src/ring/tui.py:174 +#: src/ring/tui.py:175 msgid "最久等待" msgstr "" -#: src/ring/tui.py:175 +#: src/ring/tui.py:176 msgid "回覆權限" msgstr "" -#: src/ring/tui.py:176 src/ring/tui.py:177 +#: src/ring/tui.py:177 src/ring/tui.py:178 msgid "跳過去" msgstr "" -#: src/ring/tui.py:255 +#: src/ring/tui.py:259 msgid "💡 同專案開了多個 session,裝 hook 跳轉才精準:ring install-hooks" msgstr "" -#: src/ring/tui.py:257 +#: src/ring/tui.py:261 msgid "↑/↓ 選一列,Enter 或 Space 跳到那個 session 的終端" msgstr "" -#: src/ring/tui.py:293 +#: src/ring/tui.py:328 #, python-brace-format msgid "🔔 {names} 在等你回話" msgstr "" -#: src/ring/tui.py:334 +#: src/ring/tui.py:369 #, python-brace-format msgid "→ 已跳到 {project}(來自通知)" msgstr "" -#: src/ring/tui.py:339 +#: src/ring/tui.py:374 msgid "那個 session 已不在場" msgstr "" -#: src/ring/tui.py:400 +#: src/ring/tui.py:385 +msgid "🔇 QUIET" +msgstr "" + +#: src/ring/tui.py:387 +#, python-brace-format +msgid "🔇 QUIET · 剩 {remaining}" +msgstr "" + +#: src/ring/tui.py:390 +#, python-brace-format +msgid "queue: {n}" +msgstr "" + +#: src/ring/tui.py:481 msgid " ⚠ hook 可能失效:來源檔有更新但沒有 heartbeat" msgstr "" -#: src/ring/tui.py:431 src/ring/tui.py:455 src/ring/tui.py:494 -#: src/ring/tui.py:562 +#: src/ring/tui.py:512 src/ring/tui.py:536 src/ring/tui.py:575 +#: src/ring/tui.py:644 msgid "(沒有選到 session)" msgstr "" -#: src/ring/tui.py:462 src/ring/tui.py:470 src/ring/tui.py:500 +#: src/ring/tui.py:543 src/ring/tui.py:551 src/ring/tui.py:581 #, python-brace-format msgid "{project}:{msg}" msgstr "" -#: src/ring/tui.py:468 +#: src/ring/tui.py:549 #, python-brace-format msgid "→ {project}({where})" msgstr "" -#: src/ring/tui.py:478 +#: src/ring/tui.py:559 msgid "(沒有正在等待的 session)" msgstr "" -#: src/ring/tui.py:504 +#: src/ring/tui.py:585 #, python-brace-format msgid "{project}:沒有 tmux 座標,且非 macOS 上的 iTerm2 session,無法就地回覆" msgstr "" -#: src/ring/tui.py:511 +#: src/ring/tui.py:592 #, python-brace-format msgid "{project}:讀不到 {backend} 畫面" msgstr "" -#: src/ring/tui.py:515 +#: src/ring/tui.py:596 #, python-brace-format msgid "{project}:畫面上沒有可辨識的權限對話框,未送出任何按鍵" msgstr "" -#: src/ring/tui.py:544 +#: src/ring/tui.py:626 #, python-brace-format msgid "→ {project}:已回覆權限({option})" msgstr "" -#: src/ring/tui.py:548 +#: src/ring/tui.py:630 #, python-brace-format msgid "{project}:權限對話框已不在,未送出任何按鍵" msgstr "" -#: src/ring/tui.py:549 +#: src/ring/tui.py:631 #, python-brace-format msgid "{project}:對話框內容已變化,未送出;請再按一次 p" msgstr "" -#: src/ring/tui.py:550 +#: src/ring/tui.py:632 #, python-brace-format msgid "{project}:tmux send-keys 失敗,沒能回覆" msgstr "" -#: src/ring/tui.py:551 +#: src/ring/tui.py:633 #, python-brace-format msgid "{project}:已送出但對話框還在,請跳過去確認" msgstr "" -#: src/ring/tui.py:553 +#: src/ring/tui.py:635 #, python-brace-format msgid "{project}:對話框已不在,數字落進輸入框,已補 Backspace 清掉" msgstr "" -#: src/ring/tui.py:555 +#: src/ring/tui.py:637 #, python-brace-format msgid "{project}:已送出但拿不到畫面驗證,請跳過去確認" msgstr "" -#: src/ring/tui.py:570 +#: src/ring/tui.py:652 #, python-brace-format msgid "再按一次 d 隱藏 {project}(有新活動會自動重新出現)" msgstr "" -#: src/ring/tui.py:575 +#: src/ring/tui.py:657 #, python-brace-format msgid "{project} 是仍在執行的 process,無法隱藏;請跳過去結束它" msgstr "" -#: src/ring/tui.py:583 +#: src/ring/tui.py:665 #, python-brace-format msgid "已隱藏 {project},並清掉 RiNG 狀態;有新活動會自動重新出現" msgstr "" -#: src/ring/tui.py:585 +#: src/ring/tui.py:667 #, python-brace-format msgid "已隱藏 {project}(沒有 RiNG registry 可清;有新活動會自動重新出現)" msgstr "" @@ -800,6 +850,37 @@ msgstr "" msgid "目前隱藏中:{count} 個 session" msgstr "" +#: src/ring/commands/quiet.py:16 +msgid "🔈 quiet:目前關閉" +msgstr "" + +#: src/ring/commands/quiet.py:20 +msgid "🔇 quiet:開啟中(手動解除前一直靜音)" +msgstr "" + +#: src/ring/commands/quiet.py:22 +#, python-brace-format +msgid "🔇 quiet:開啟中,剩 {remaining}" +msgstr "" + +#: src/ring/commands/quiet.py:37 +msgid "🔇 quiet 已開啟(手動解除前一直靜音)" +msgstr "" + +#: src/ring/commands/quiet.py:43 +msgid "🔈 quiet 已解除" +msgstr "" + +#: src/ring/commands/quiet.py:49 +#, python-brace-format +msgid "無效的 duration:{value}(例如 30m、1h)" +msgstr "" + +#: src/ring/commands/quiet.py:53 +#, python-brace-format +msgid "🔇 quiet 已開啟,{duration} 後自動解除" +msgstr "" + #: src/ring/commands/stats.py:24 msgid "全部" msgstr "" @@ -854,11 +935,22 @@ msgstr "" msgid "沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)" msgstr "" -#: src/ring/notify/__init__.py:139 +#: src/ring/notify/__init__.py:165 +#, python-brace-format +msgid "另有 {n} 個 session 在等你" +msgid_plural "另有 {n} 個 session 在等你" +msgstr[0] "" +msgstr[1] "" + +#: src/ring/notify/__init__.py:199 msgid "💡 裝 terminal-notifier 可點擊通知直接跳回 session:brew install terminal-notifier" msgstr "" -#: src/ring/notify/base.py:41 +#: src/ring/notify/base.py:54 +msgid "RiNG · 還有人在等你" +msgstr "" + +#: src/ring/notify/base.py:55 #, python-brace-format msgid "RiNG · {project} 在等你回話" msgstr "" diff --git a/src/ring/notify/__init__.py b/src/ring/notify/__init__.py index b340314..7c1a869 100644 --- a/src/ring/notify/__init__.py +++ b/src/ring/notify/__init__.py @@ -17,16 +17,20 @@ import shutil import sys +import time +from dataclasses import replace from pathlib import Path from ring.config import get_config from ring.i18n import gettext as _ +from ring.i18n import ngettext from ring.notify.base import Notifier from ring.notify.notify_send import notifier as _notify_send from ring.notify.ntfy import notifier as _ntfy from ring.notify.osascript_notifier import notifier as _osascript from ring.notify.terminal_notifier import notifier as _terminal_notifier from ring.notify.webhook import notifier as _webhook +from ring.notify_queue import enqueue, quiet_active, try_claim_leading_edge from ring.registry import Session # 一次性安裝引導的 marker 檔路徑。 @@ -84,6 +88,21 @@ def _select_notifier(backend: str) -> Notifier | None: def notify_waiting(sessions: list[Session]) -> str | None: """對一批「新轉為等你」的 session 發系統通知。 + 開頭是合流/quiet 的單一 gate(求值順序:quiet 優先於 debounce,見專案 plan 的節流互動 + 矩陣)——hook 路徑與 TUI 代發路徑都經過這裡,一次覆蓋兩條路徑: + + 1. quiet active → 整批 enqueue、連 leading edge 都不發,直接回傳(跟 backend=none + 一致的「說到做到」語意)。 + 2. ``notify_debounce_seconds>0`` 且目前在合流視窗內 → enqueue、不發。 + 3. 視窗未開(或 debounce=0)→ 照常往下走現有發送路徑;debounce>0 時順便原子宣告 + leading edge(``try_claim_leading_edge``:這批立即發,後續同視窗內的合併進 queue, + 由 ``flush_if_due`` 懶惰補發)。 + + debounce 分支用 ``try_claim_leading_edge`` 而非「先讀 window_open() 再 open_window()」 + 兩段式:兩個 process 同時判定「視窗未開」時,check-then-act 若拆成兩次上鎖,會同時 + 都以為自己是 leading edge、都照常發送——``try_claim_leading_edge`` 把判斷與宣告包進 + 同一個鎖區塊,兩個 process 只有一個能拿到 True。 + 依 config ``notify_backend`` 選一個 notifier 後端發送(見 ``_select_notifier``)。 auto 模式下若只選到不支援點擊的後端、且在 macOS 上,回傳一次性的安裝引導字串 (建議裝 terminal-notifier 取得點擊跳轉);其餘情況回 ``None``。失敗一律安靜吞掉。 @@ -94,7 +113,18 @@ def notify_waiting(sessions: list[Session]) -> str | None: if not sessions: return None + now = time.time() + if quiet_active(now): + enqueue(sessions) + return None + cfg = get_config() + debounce = cfg.notify_debounce_seconds + if debounce > 0 and not try_claim_leading_edge(now, debounce): + enqueue(sessions) + return None + # debounce<=0,或 debounce>0 且剛剛原子拿到 leading edge → 照常往下走發送路徑。 + backend = cfg.notify_backend if backend == "none": return None # 「完全不發通知」說到做到,notify_also 也不例外 @@ -110,6 +140,36 @@ def notify_waiting(sessions: list[Session]) -> str | None: return None +def notify_summary(count: int, sample_session: Session) -> None: + """flush 時發一則彙總通知:「另有 {n} 個 session 在等你」(單複數走 i18n)。 + + 走跟 ``notify_waiting`` 相同的後端選擇,但只送一個標記為 ``is_summary=True`` 的 Session + (見 ``notify.base.is_summary_session``),讓 ``notify_title`` / ``notify_message`` 改用 + 彙總句式——每個後端仍是同一條 ``send(list[Session])`` 路徑,不必碰任何後端模組的送出實作, + 就能達成「真的只發一則」而非逐 session 各發。**不覆寫 ``session_id``**:沿用 + ``sample_session`` 本身真實的 id,點擊這則通知(``ring focus ``)會正確跳到 + 那個仍在等你的真實 session,而不是找不到 sentinel id 靜默失敗。 + + :param count: 這批合流的 session 數;``<=0`` 時直接回傳,不發。 + :param sample_session: 任一被合流的 session,借用其欄位組彙總 payload(通常是 pop 出的第一筆)。 + """ + if count <= 0: + return + cfg = get_config() + backend = cfg.notify_backend + if backend == "none": + return + notifier = _select_notifier(backend) + if notifier is None: + return + text = ngettext("另有 {n} 個 session 在等你", "另有 {n} 個 session 在等你", count, n=count) + summary_session = replace(sample_session, is_summary=True, waiting_detail=text) + try: + notifier.send([summary_session]) + except Exception: + pass + + def _send_also(sessions: list[Session], also: tuple[str, ...], primary: Notifier | None) -> None: """notify_also 的加發:主後端之外,再對指定名稱的後端各發一份。 @@ -144,4 +204,4 @@ def _maybe_show_install_hint() -> str | None: return None -__all__ = ["Notifier", "notifiers", "notify_waiting", "register_notifier"] +__all__ = ["Notifier", "notifiers", "notify_summary", "notify_waiting", "register_notifier"] diff --git a/src/ring/notify/base.py b/src/ring/notify/base.py index 522f168..af048c9 100644 --- a/src/ring/notify/base.py +++ b/src/ring/notify/base.py @@ -36,8 +36,22 @@ def display_name(session: Session) -> str: return get_label(session.session_id) or session.project +def is_summary_session(session: Session) -> bool: + """這個 Session 是不是彙總通知用的標記(見 ``Session.is_summary`` / ``ring.notify.notify_summary``)。 + + 彙總只需要「發一則」而非逐 session 發,靠這個 flag 讓 notify_title / notify_message + 改走彙總句式,不必碰任何一個後端模組的 send() 實作——各後端仍是呼叫同一個 + send(list[Session]),只是那個 Session 的 ``is_summary`` 剛好是 True。``session_id`` + 本身維持真實值(借用某個真的被合流的 session),點擊這則通知能正確 focus 過去, + 不會像 sentinel id 那樣找不到 session。 + """ + return session.is_summary + + def notify_title(session: Session) -> str: - """通知標題——「哪個 session 在等你」,全後端共用一句。""" + """通知標題——「哪個 session 在等你」,全後端共用一句。彙總通知改用固定句。""" + if is_summary_session(session): + return _("RiNG · 還有人在等你") return _("RiNG · {project} 在等你回話", project=display_name(session)) @@ -45,6 +59,9 @@ def notify_message(session: Session) -> str: """通知內文——它在等什麼(hook 有給 detail 時)+去哪(完整路徑或 tmux 座標)。 標題已經說了「誰在等你」,內文就補「等什麼、去哪」,讓你看一眼就能決定要不要現在回去。 + 彙總通知(sentinel session)直接用 ``waiting_detail`` 存的彙總句,不附位置。 """ + if is_summary_session(session): + return session.waiting_detail location = f"📍 {session.location}" return f"{session.waiting_detail}\n{location}" if session.waiting_detail else location diff --git a/src/ring/notify_queue.py b/src/ring/notify_queue.py new file mode 100644 index 0000000..4f167ad --- /dev/null +++ b/src/ring/notify_queue.py @@ -0,0 +1,332 @@ +"""通知合流(debounce)+暫時 quiet mode 的所有檔案狀態操作。 + +跨 process 沒有常駐 daemon,這裡是「被 hold 住的通知」唯一的落地層:hook 事件(每次都是 +一次性 subprocess)與 TUI 輪詢都經由這個模組讀寫同一份磁碟狀態,達成跨 process 合流。 + +機制概述 +--------- +- ``~/.config/ring/notify-queue.json``:debounce 視窗狀態(``window_opened_at``)+被 hold + 住的 session 清單(去重,key 是 session_id)。debounce 與 quiet 共用同一份 queue—— + 不論被 hold 的原因是哪個,flush 邏輯一致(見 ``flush_if_due``)。 +- ``~/.config/ring/quiet``:暫時全域靜音狀態,``{"until": , "since": }``。 + ``until=None`` 代表手動解除前一直靜音;``until=`` 到期即視為非 active(讀時判定, + 跟 ``ipc.py`` 的 TTL 到期即失效同一套風格)。 + +設計原則(仿 ``ipc.py`` / ``registry.py`` 既有 pattern) +--------------------------------------------------------- +- 純 stdlib,零新依賴。 +- 跨 process 的 read-modify-write 一律用 ``fcntl.flock`` 保護(抄 ``registry._hidden_sessions_lock``)。 +- 所有檔案操作失敗安靜吞掉,不打斷 hook / TUI 主流程。 +- 路徑常數可在測試中用關鍵字參數注入,隔離測試環境。 +""" + +from __future__ import annotations + +import fcntl +import json +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import asdict +from pathlib import Path +from typing import Any + +from ring.config import get_config +from ring.i18n import gettext as _ +from ring.registry import Session, Status + +_CONFIG_DIR: Path = Path.home() / ".config" / "ring" +_QUEUE_PATH: Path = _CONFIG_DIR / "notify-queue.json" +_QUIET_PATH: Path = _CONFIG_DIR / "quiet" + + +# --------------------------------------------------------------------------- 共用鎖 + 讀寫 helper + + +@contextmanager +def _locked(path: Path) -> Iterator[None]: + """跨 process 的 read-modify-write 臨界區(抄 ``registry._hidden_sessions_lock``)。""" + path.parent.mkdir(parents=True, exist_ok=True) + lock_path = path.with_name(path.name + ".lock") + with open(lock_path, "w", encoding="utf-8") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(fh, fcntl.LOCK_UN) + + +def _read_json_locked(path: Path) -> dict[str, Any]: + """呼叫端須已持有 ``_locked(path)``。壞檔/缺檔一律視為空狀態,不拋例外。""" + try: + raw = json.loads(path.read_text(encoding="utf-8")) + if isinstance(raw, dict): + return raw + except Exception: + pass + return {} + + +def _write_json_locked(data: dict[str, Any], *, path: Path) -> None: + """呼叫端須已持有 ``_locked(path)``。atomic tmp+replace,失敗安靜吞掉。""" + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + except OSError: + pass + + +# --------------------------------------------------------------------------- Session ↔ dict + + +def _session_to_dict(session: Session) -> dict[str, Any]: + d = asdict(session) + d["status"] = session.status.value + return d + + +def _session_from_dict(d: dict[str, Any]) -> Session | None: + """壞資料(缺必要欄位/型別不對)安靜跳過,回傳 ``None``。""" + data = dict(d) + try: + data["status"] = Status(data.get("status", Status.WAITING.value)) + except ValueError: + return None + todo = data.get("todo") + if isinstance(todo, list) and len(todo) == 2: + data["todo"] = (todo[0], todo[1]) + # 未知欄位(例如舊版留下的)會讓 Session(**data) 直接炸;只保留 Session 認得的欄位。 + known = {f for f in Session.__dataclass_fields__} + data = {k: v for k, v in data.items() if k in known} + try: + return Session(**data) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- queue(enqueue / pop_all / peek_count) + + +def enqueue(sessions: list[Session], *, queue_path: Path | None = None) -> None: + """把一批 session 併入 queue(以 session_id 去重合併,新資料覆蓋舊的)。""" + if not sessions: + return + path = queue_path or _QUEUE_PATH + with _locked(path): + state = _read_json_locked(path) + bucket = state.get("sessions") + if not isinstance(bucket, dict): + bucket = {} + for s in sessions: + bucket[s.session_id] = _session_to_dict(s) + state["sessions"] = bucket + _write_json_locked(state, path=path) + + +def pop_all(*, queue_path: Path | None = None) -> list[Session]: + """取出 queue 裡全部 session 並清空(消費即焚)。""" + path = queue_path or _QUEUE_PATH + with _locked(path): + state = _read_json_locked(path) + bucket = state.get("sessions") + result: list[Session] = [] + if isinstance(bucket, dict): + for raw in bucket.values(): + if isinstance(raw, dict): + s = _session_from_dict(raw) + if s is not None: + result.append(s) + state["sessions"] = {} + _write_json_locked(state, path=path) + return result + + +def peek_count(*, queue_path: Path | None = None) -> int: + """唯讀計數,不清空——給視覺化(TUI header badge)用。""" + path = queue_path or _QUEUE_PATH + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return 0 + if not isinstance(raw, dict): + return 0 + bucket = raw.get("sessions") + return len(bucket) if isinstance(bucket, dict) else 0 + + +# --------------------------------------------------------------------------- debounce 視窗狀態 + + +def try_claim_leading_edge(now: float, seconds: float, *, queue_path: Path | None = None) -> bool: + """原子版「視窗是否開著」判斷 +「開新視窗」動作,兩者包在同一個鎖區塊內。 + + ``window_open()`` 接著呼叫 ``open_window()`` 是兩次獨立的 ``_locked()`` 加解鎖,中間 + 沒有鎖保護——兩個 process 同時在「視窗未開」那個瞬間各自呼叫,會同時判定自己是 + leading edge、都照常發送(debounce 對這輪完全失效)。這個函式把 check-then-act 併進 + 單一臨界區,保證同一視窗只有一個呼叫端能拿到 leading edge。 + + :returns: ``True`` 代表這次呼叫拿到 leading edge(視窗未開,已原子性地幫它開新視窗, + 呼叫端應該照常發送這批);``False`` 代表已經有人在視窗內(呼叫端應該 enqueue,不發)。 + """ + path = queue_path or _QUEUE_PATH + with _locked(path): + state = _read_json_locked(path) + opened_at = state.get("window_opened_at") + is_open = isinstance(opened_at, (int, float)) and (now - float(opened_at)) < seconds + if is_open: + return False + state["window_opened_at"] = now + _write_json_locked(state, path=path) + return True + + +# --------------------------------------------------------------------------- quiet 狀態 + + +def set_quiet(until: float | None, *, quiet_path: Path | None = None) -> None: + """開啟 quiet:``until=None`` 手動解除前一直靜音;``until=`` 到期自動解除。""" + path = quiet_path or _QUIET_PATH + payload: dict[str, Any] = {"until": until, "since": time.time()} + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(payload), encoding="utf-8") + tmp.replace(path) + except OSError: + pass + + +def clear_quiet(*, quiet_path: Path | None = None) -> None: + """手動解除 quiet(``ring quiet off``)。""" + path = quiet_path or _QUIET_PATH + try: + path.unlink() + except OSError: + pass + + +def _read_quiet(*, quiet_path: Path | None = None, now: float) -> dict[str, Any] | None: + """讀 quiet 狀態;到期(``until`` 已過)視為非 active,順手刪掉 stale 檔(抄 ipc TTL 風格)。""" + path = quiet_path or _QUIET_PATH + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + if not isinstance(raw, dict): + return None + until = raw.get("until") + if until is not None and isinstance(until, (int, float)) and now >= float(until): + try: + path.unlink() + except OSError: + pass + return None + return raw + + +def quiet_active(now: float, *, quiet_path: Path | None = None) -> bool: + """quiet 目前是否生效(含到期即視為非 active 的判定)。""" + return _read_quiet(quiet_path=quiet_path, now=now) is not None + + +def quiet_remaining(now: float, *, quiet_path: Path | None = None) -> float | None: + """剩餘秒數;quiet 未開啟/已過期 → ``None``;``until=None``(無限期)也回 ``None``。""" + data = _read_quiet(quiet_path=quiet_path, now=now) + if data is None: + return None + until = data.get("until") + if until is None or not isinstance(until, (int, float)): + return None + remaining = float(until) - now + return remaining if remaining > 0 else None + + +def format_remaining(seconds: float) -> str: + """人類可讀的剩餘時間,供 ``ring quiet`` CLI 與 TUI header badge 共用。""" + total = max(0, int(seconds)) + if total < 60: + return _("{s} 秒", s=total) + minutes = total // 60 + if minutes < 60: + return _("{m} 分", m=minutes) + hours = minutes // 60 + return _("{h} 小時", h=hours) + + +# --------------------------------------------------------------------------- flush + + +def _try_pop_for_flush_locked(now: float, *, force: bool, queue_path: Path) -> list[Session] | None: + """單一鎖區塊內原子完成「該不該 flush」的判斷 + pop + 清視窗。 + + 拆成「先 window_open() 判斷、再各自 pop_all()/clear_window()」的多段式會有縫隙: + process H 判定「視窗已過期」到它真的 pop+clear 之間,若另一個 process W 恰好在這個 + 縫隙 open_window() 開了新視窗(合法的下一輪 leading edge),H 的 clear_window() 會 + 把 W 剛開的視窗殺掉——這個函式把「讀狀態→判斷是否該 flush→pop→清視窗」全部包進 + 同一個 ``_locked()``,杜絕這個縫隙。 + + :returns: ``None`` 代表這次不該 flush(非 force 且仍在 debounce 視窗內,或 queue 本來 + 就空);非 ``None`` 的 list(已從 queue 原子性地取出並清空)代表這次真的 flush 了。 + """ + with _locked(queue_path): + state = _read_json_locked(queue_path) + if not force: + debounce = get_config().notify_debounce_seconds + opened_at = state.get("window_opened_at") + still_open = debounce > 0 and isinstance(opened_at, (int, float)) and (now - float(opened_at)) < debounce + if still_open: + return None + + bucket = state.get("sessions") + sessions: list[Session] = [] + if isinstance(bucket, dict): + for raw in bucket.values(): + if isinstance(raw, dict): + s = _session_from_dict(raw) + if s is not None: + sessions.append(s) + if not sessions: + return None + + state["sessions"] = {} + state["window_opened_at"] = None + _write_json_locked(state, path=queue_path) + return sessions + + +def flush_if_due( + *, + now: float | None = None, + force: bool = False, + queue_path: Path | None = None, + quiet_path: Path | None = None, +) -> None: + """三個懶惰觸發源共用的 flush 入口:hook 主流程開頭、TUI 輪詢、``ring quiet off``。 + + - quiet active 時一律不 flush(``force=True`` 除外)——quiet 期間累積,等使用者主動 + 解除才處理。 + - 非 force:只有「不在 debounce 視窗內」時才 flush(``notify_debounce_seconds<=0`` 時 + 視窗永遠不會開啟,等同一律可 flush,讓純 quiet 累積的項目一樣能被懶惰觸發清掉)。 + - force=True(``ring quiet off``):跳過 quiet/視窗判斷,queue 有東西就直接 flush。 + + 「該不該 flush」的判斷與「pop+清視窗」的動作是同一個鎖區塊內完成的原子操作(見 + ``_try_pop_for_flush_locked``),flush 完才呼叫 ``ring.notify.notify_summary`` 發一則 + 彙總——延後 import 避免 notify_queue ↔ notify 循環依賴。失敗安靜吞掉,絕不擋住 + hook / TUI 主流程。 + """ + current = now if now is not None else time.time() + if not force and quiet_active(current, quiet_path=quiet_path): + return + + path = queue_path or _QUEUE_PATH + sessions = _try_pop_for_flush_locked(current, force=force, queue_path=path) + if not sessions: + return + try: + from ring.notify import notify_summary + + notify_summary(len(sessions), sessions[0]) + except Exception: + pass diff --git a/src/ring/registry.py b/src/ring/registry.py index 3f5ebaa..2714d58 100644 --- a/src/ring/registry.py +++ b/src/ring/registry.py @@ -315,6 +315,10 @@ class Session: kind: str = "foreground" # "foreground" | "agent";背景 agent(bg-pty-host 承載)由 discover 貼標 _tail_kind: str = field(default="none", repr=False, compare=False) # 內部:scan 路徑暫存對話尾判定 origin_cwd: str = "" # 開場 cwd(session 第一筆帶 cwd 紀錄),用於歸屬;空時 fallback 到 cwd + # 合流彙總通知(見 ring.notify.notify_summary)用的標記:True 代表這不是真實 session, + # 只是借一個「真實 session 的欄位」組出來的通知 payload,讓 notify_title / notify_message + # 改用彙總句式。session_id 本身維持真實值(不覆寫成 sentinel),點擊通知才能正確 focus。 + is_summary: bool = field(default=False, repr=False, compare=False) @property def project(self) -> str: diff --git a/src/ring/tui.py b/src/ring/tui.py index fbd58bd..0906fa4 100644 --- a/src/ring/tui.py +++ b/src/ring/tui.py @@ -40,6 +40,7 @@ from ring.i18n import set_lang from ring.ipc import clear_tui_presence, read_focus_request, write_tui_presence from ring.labels import get_label, load_labels, set_label +from ring.notify_queue import flush_if_due, format_remaining, peek_count, quiet_active, quiet_remaining from ring.registry import Session, Status, delete_session_state, hide_session, running_agent_pids from ring.watcher import WaitingAlertScheduler @@ -298,12 +299,20 @@ def _ring_on_waiting_alerts(self, alerts: list[Session]) -> None: codex + waiting_kind="permission" 只可能來自逾時判定(hook 對 codex 裸 PermissionRequest 一律記 working;AskUserQuestion 形狀的是 "question"), 不會跟 hook 端的通知重複。失敗安靜吞,不影響看板。 + + ``notify_waiting(promoted)`` 一定要先呼叫,quiet 判斷放到它後面: + ``WaitingAlertScheduler.feed()`` 呼叫當下就已經把這批 session 記成「這輪已提醒 + 過」(不管呼叫端事後有沒有真的送出提醒),若在呼叫 ``notify_waiting`` 之前就 + 因為 quiet active 整段 return,這個 codex 核可等待就會被永久漏掉——它不會被 + ``enqueue()``,quiet 解除後的彙總 flush 也就永遠等不到它,而不是單純延後 + (scheduler 要等 repeat 排程才會再排 due,這段期間內若請求被別的方式解決, + 提醒就徹底消失,沒有任何補發路徑)。``notify_waiting`` 自己的 quiet gate + 會在 quiet active 時把它 enqueue、不發送——這才是 quiet 期間該有的「延後」行為。 + quiet active 時只跳過 in-app 鈴/toast(bell / self.notify),系統通知交給 + ``notify_waiting`` 自己的 gate 決定。 """ if not alerts: return - self.bell() - names = ", ".join(sorted(self._display_name(s) for s in alerts)) - self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8) promoted = [s for s in alerts if s.provider == "codex" and s.waiting_kind == "permission"] if promoted: try: @@ -312,6 +321,11 @@ def _ring_on_waiting_alerts(self, alerts: list[Session]) -> None: notify_waiting(promoted) except Exception: pass + if quiet_active(time.time()): + return + self.bell() + names = ", ".join(sorted(self._display_name(s) for s in alerts)) + self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8) def _activate_own_window(self) -> None: """把 RiNG 自己的終端視窗帶到前景(best-effort,失敗安靜吞)。 @@ -361,10 +375,31 @@ def _poll_focus_request(self) -> None: self._set_status(msg) self.notify(msg, severity="warning", timeout=8) + def _quiet_badge(self) -> str: + """quiet 狀態 + debounce queue 計數的 header badge;都沒有時回空字串(零視覺負擔)。""" + now = time.time() + parts: list[str] = [] + if quiet_active(now): + remaining = quiet_remaining(now) + if remaining is None: + parts.append(_("🔇 QUIET")) + else: + parts.append(_("🔇 QUIET · 剩 {remaining}", remaining=format_remaining(remaining))) + count = peek_count() + if count > 0: + parts.append(_("queue: {n}", n=count)) + return " | " + " ".join(parts) if parts else "" + def _reload(self) -> None: # 每次刷新都續寫 presence,避免 TUI 開超過 TTL 後 `ring focus` 誤判 TUI 沒在跑、 # 退回去跳 session 自己的終端(scan 模式常沒 tty → 跳轉失敗)。 write_tui_presence() + # TUI 輪詢是三個懶惰 flush 觸發源之一:開著看板時比 headless(靠下個 hook 事件)更快 + # 補發合流的彙總通知。失敗安靜吞,不影響看板本身。 + try: + flush_if_due() + except Exception: + pass self._sessions = board(self._show_all) self._apply_permission_acks() table = self.query_one(DataTable) @@ -385,7 +420,7 @@ def _reload(self) -> None: # 這裡只留 TUI 自己的 in-app 響鈴 / 訊息列與醒目標記,不重複發系統通知。 alerts = self._alerts.feed(self._sessions) self._ring_on_waiting_alerts(alerts) - self.sub_title = _header(len(self._sessions), len(running_agent_pids())) + self.sub_title = _header(len(self._sessions), len(running_agent_pids())) + self._quiet_badge() labels = load_labels() table.clear() for s in self._sessions: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e3490a..fb8cb1d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import time from pathlib import Path from unittest.mock import patch @@ -7,6 +8,14 @@ from ring.registry import Session, Status +@pytest.fixture(autouse=True) +def _hermetic_notify_queue(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """把 debounce queue/quiet 狀態檔導去 tmp_path,避免 ``ring quiet`` 測試碰到機器上 + 真實的狀態檔。""" + monkeypatch.setattr("ring.notify_queue._QUEUE_PATH", tmp_path / "notify-queue.json") + monkeypatch.setattr("ring.notify_queue._QUIET_PATH", tmp_path / "quiet") + + def _sessions() -> list[Session]: return [Session("a", "/x/maigo", Status.WORKING, 0.0, "→ Edit", "scan")] @@ -734,6 +743,67 @@ def test_gc_bad_duration_returns_two(capsys: pytest.CaptureFixture[str]) -> None assert "nope" in capsys.readouterr().err +# --------------------------------------------------------------------------- +# quiet 子命令 +# --------------------------------------------------------------------------- + + +def test_quiet_help_lists_in_top_level_help(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit): + cli.main(["--help"]) + assert "quiet" in capsys.readouterr().out + + +def test_quiet_help_does_not_run(capsys: pytest.CaptureFixture[str]) -> None: + with patch.object(cli, "run_quiet") as mock_quiet: + rc = cli.main(["quiet", "--help"]) + assert rc == 0 + mock_quiet.assert_not_called() + assert "usage: ring quiet" in capsys.readouterr().out + + +def test_quiet_no_args_shows_off_by_default(capsys: pytest.CaptureFixture[str]) -> None: + rc = cli.main(["quiet", "--lang", "en"]) + assert rc == 0 + assert "off" in capsys.readouterr().out.lower() + + +def test_quiet_on_activates_and_status_reflects_it(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["quiet", "on", "--lang", "en"]) == 0 + capsys.readouterr() + assert cli.main(["quiet", "--lang", "en"]) == 0 + assert "on" in capsys.readouterr().out.lower() + + +def test_quiet_off_clears_and_flushes(capsys: pytest.CaptureFixture[str]) -> None: + from ring import notify_queue + + notify_queue.set_quiet(None) + notify_queue.enqueue([Session("a", "/x/p", Status.WAITING, 0.0, "-", "hook")]) + with patch("ring.notify.notify_summary") as mock_summary: + rc = cli.main(["quiet", "off", "--lang", "en"]) + assert rc == 0 + mock_summary.assert_called_once() + assert notify_queue.quiet_active(time.time()) is False + assert notify_queue.peek_count() == 0 + + +def test_quiet_duration_sets_until(capsys: pytest.CaptureFixture[str]) -> None: + from ring import notify_queue + + now = time.time() + assert cli.main(["quiet", "30m", "--lang", "en"]) == 0 + remaining = notify_queue.quiet_remaining(now) + assert remaining is not None + assert 29 * 60 < remaining <= 30 * 60 + 5 # 上界留一點餘裕:cli.main 內部才呼叫 time.time() + + +def test_quiet_bad_duration_returns_two(capsys: pytest.CaptureFixture[str]) -> None: + rc = cli.main(["quiet", "not-a-duration", "--lang", "en"]) + assert rc == 2 + assert "not-a-duration" in capsys.readouterr().err + + # --------------------------------------------------------------------------- # --format json / oneline(機器可讀輸出) # --------------------------------------------------------------------------- @@ -808,6 +878,52 @@ def test_format_rejects_watch(capsys: pytest.CaptureFixture[str]) -> None: assert "--format" in capsys.readouterr().err +# --------------------------------------------------------------------------- +# headless watch:懶惰 flush 合流 queue(修正 A) +# --------------------------------------------------------------------------- + + +def test_watch_headless_flushes_due_queue(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """headless(無 rich)watch 每輪輪詢都要懶惰 flush 一次到期的合流 queue。""" + import ring.notify_queue as notify_queue + + monkeypatch.setattr(cli, "HAVE_RICH", False) + monkeypatch.setattr(cli, "board", lambda show_all: _sessions()) + monkeypatch.setattr(cli, "running_agent_pids", lambda: [1]) + notify_queue.enqueue([Session("waiting-1", "/x", Status.WAITING, 0.0, "→ Edit", "hook")]) + summary_calls: list[int] = [] + monkeypatch.setattr("ring.notify.notify_summary", lambda count, sample: summary_calls.append(count)) + + rc = cli.watch(interval=0, count=1, show_all=False, show_legend=False) + capsys.readouterr() + + assert rc == 0 + assert summary_calls == [1] + assert notify_queue.peek_count() == 0 + + +def test_watch_headless_does_not_flush_while_quiet_active( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """quiet active 時 headless watch 輪詢 → 不 flush(queue 保留)。""" + import ring.notify_queue as notify_queue + + monkeypatch.setattr(cli, "HAVE_RICH", False) + monkeypatch.setattr(cli, "board", lambda show_all: _sessions()) + monkeypatch.setattr(cli, "running_agent_pids", lambda: [1]) + notify_queue.enqueue([Session("waiting-1", "/x", Status.WAITING, 0.0, "→ Edit", "hook")]) + notify_queue.set_quiet(None) + summary_calls: list[int] = [] + monkeypatch.setattr("ring.notify.notify_summary", lambda count, sample: summary_calls.append(count)) + + rc = cli.watch(interval=0, count=1, show_all=False, show_legend=False) + capsys.readouterr() + + assert rc == 0 + assert summary_calls == [] + assert notify_queue.peek_count() == 1 + + # --------------------------------------------------------------------------- # completion 子命令 # --------------------------------------------------------------------------- diff --git a/tests/test_config.py b/tests/test_config.py index 5c26bc4..ade1a38 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -51,6 +51,29 @@ def test_waiting_cooldown_seconds_negative_clamped_to_zero(tmp_path: Path) -> No assert load(p).waiting_cooldown_seconds == 0 +def test_notify_debounce_seconds_defaults_to_zero(tmp_path: Path) -> None: + """沒設 → 預設 0(關閉,向後相容:每次轉入都各自發)。""" + assert load(tmp_path / "nope.toml").notify_debounce_seconds == 0 + + +def test_notify_debounce_seconds_parses_positive(tmp_path: Path) -> None: + p = tmp_path / "config.toml" + p.write_text("notify_debounce_seconds = 5\n") + assert load(p).notify_debounce_seconds == 5 + + +def test_notify_debounce_seconds_negative_clamped_to_zero(tmp_path: Path) -> None: + p = tmp_path / "config.toml" + p.write_text("notify_debounce_seconds = -5\n") + assert load(p).notify_debounce_seconds == 0 + + +def test_notify_debounce_seconds_bad_type_falls_back_to_zero(tmp_path: Path) -> None: + p = tmp_path / "config.toml" + p.write_text('notify_debounce_seconds = "soon"\n') + assert load(p).notify_debounce_seconds == 0 + + def test_notify_backend_parses_valid(tmp_path: Path) -> None: p = tmp_path / "config.toml" p.write_text('notify_backend = "osascript"\n') @@ -167,3 +190,23 @@ def test_detect_stop_questions_parses_and_defaults(tmp_path: Path) -> None: p.write_text("detect_stop_questions = false\n") assert load(p).detect_stop_questions is False assert load(tmp_path / "nope.toml").detect_stop_questions is True + + +def test_unknown_key_warns_and_still_loads(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """打錯字的鍵(未知鍵)→ stderr 印一次警告,config 仍正常載入(不 raise)。""" + p = tmp_path / "config.toml" + p.write_text('intervl = 1.5\nlang = "en"\n') + cfg = load(p) + err = capsys.readouterr().err + assert cfg.lang == "en" # 已知鍵仍照常生效 + assert cfg.interval == Config().interval # 打錯字的鍵被忽略,用預設值 + assert "intervl" in err + assert str(p) in err + + +def test_known_keys_do_not_warn(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """全部合法鍵(含巢狀 [colors] table)→ 不印任何警告。""" + p = tmp_path / "config.toml" + p.write_text('lang = "en"\ninterval = 1.5\n\n[colors]\nwaiting = "bold magenta"\n') + load(p) + assert capsys.readouterr().err == "" diff --git a/tests/test_hook.py b/tests/test_hook.py index cd59e79..191eaad 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -8,7 +8,7 @@ import ring.hook as hook from ring.config import Config from ring.hook import _is_ring_hook_command, install_hooks, uninstall_hooks -from ring.registry import Status +from ring.registry import Session, Status @pytest.fixture(autouse=True) @@ -18,10 +18,14 @@ def _hermetic_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: 同時清空 notifier registry:hook 現在會在 WAITING 事件就地發系統通知,darwin 上 osascript 永遠可用,不擋會在跑測試時噴真實通知。空 registry → _select_notifier 回 None → notify_waiting no-op。要驗證「有發」的測試自己注入 spy notifier。 - stats 的狀態轉換 log 也導去 tmp,避免測試寫進使用者的 events.jsonl。""" + stats 的狀態轉換 log 也導去 tmp,避免測試寫進使用者的 events.jsonl。run_hook 開頭的 + flush_if_due 也要導去 tmp,避免測試碰到機器上真實的 debounce queue / quiet 狀態檔。""" monkeypatch.setattr("ring.hook.get_config", lambda: Config()) monkeypatch.setattr("ring.notify._NOTIFIERS", []) monkeypatch.setattr("ring.stats.EVENTS_PATH", tmp_path / "events.jsonl") + monkeypatch.setattr("ring.notify_queue._QUEUE_PATH", tmp_path / "notify-queue.json") + monkeypatch.setattr("ring.notify_queue._QUIET_PATH", tmp_path / "quiet") + monkeypatch.setattr("ring.notify_queue.get_config", lambda: Config()) def _feed(monkeypatch: pytest.MonkeyPatch, payload: dict[str, Any]) -> None: @@ -300,6 +304,39 @@ def test_non_waiting_event_does_not_notify_in_hook(monkeypatch: pytest.MonkeyPat assert spy.sent == [] +def test_run_hook_flushes_due_queue_headless(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """headless(沒開 TUI):queue 有累積時,任何 hook 事件開頭都要懶惰 flush 一次彙總。""" + import ring.notify_queue as notify_queue + + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + notify_queue.enqueue([Session("waiting-1", "/x", Status.WAITING, 0.0, "→ Edit", "hook")]) + summary_calls: list[int] = [] + monkeypatch.setattr("ring.notify.notify_summary", lambda count, sample: summary_calls.append(count)) + _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "Stop", "cwd": "/proj"}) + + assert hook.run_hook() == 0 + + assert summary_calls == [1] + assert notify_queue.peek_count() == 0 + + +def test_run_hook_does_not_flush_while_quiet_active(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """quiet active 時跑 hook 主流程 → 不 flush(queue 保留)。""" + import ring.notify_queue as notify_queue + + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + notify_queue.enqueue([Session("waiting-1", "/x", Status.WAITING, 0.0, "→ Edit", "hook")]) + notify_queue.set_quiet(None) + summary_calls: list[int] = [] + monkeypatch.setattr("ring.notify.notify_summary", lambda count, sample: summary_calls.append(count)) + _feed(monkeypatch, {"session_id": "s1", "hook_event_name": "Stop", "cwd": "/proj"}) + + assert hook.run_hook() == 0 + + assert summary_calls == [] + assert notify_queue.peek_count() == 1 + + def test_waiting_flap_within_cooldown_suppresses_second_hook_notification( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/test_notify.py b/tests/test_notify.py index 4c3949a..fe1ff38 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -2,14 +2,17 @@ from __future__ import annotations +import time from collections.abc import Callable from pathlib import Path from unittest.mock import MagicMock, patch import pytest +import ring.notify_queue as notify_queue from ring.config import Config -from ring.notify import notify_waiting +from ring.notify import notify_summary, notify_waiting +from ring.notify.base import is_summary_session from ring.registry import Session, Status @@ -30,6 +33,13 @@ def _hermetic_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(target, lambda: Config()) +@pytest.fixture +def _hermetic_queue(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """把 debounce queue/quiet 狀態檔指到 tmp_path,避免測試碰到機器上的真實狀態。""" + monkeypatch.setattr(notify_queue, "_QUEUE_PATH", tmp_path / "notify-queue.json") + monkeypatch.setattr(notify_queue, "_QUIET_PATH", tmp_path / "quiet") + + def _which_only(*available: str) -> Callable[[str], str | None]: """模擬 shutil.which:只有指定的 binary「裝了」,其餘回 None。""" avail = set(available) @@ -645,3 +655,172 @@ def test_title_falls_back_to_project(self, monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setattr(base, "get_label", lambda sid, **kw: "") assert "maigo" in base.notify_title(_s("uuid-1", "maigo")) + + +class TestQuietGate: + """notify_waiting 開頭的 quiet gate(求值順序見 notify_waiting docstring)。""" + + def test_quiet_active_enqueues_and_sends_nothing(self, _hermetic_queue: None) -> None: + notify_queue.set_quiet(None) + with ( + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + notify_waiting([_s("a", "proj")]) + mock_run.assert_not_called() + assert notify_queue.peek_count() == 1 + + def test_quiet_inactive_sends_normally(self, _hermetic_queue: None) -> None: + with ( + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_waiting([_s("a", "proj")]) + mock_run.assert_called_once() + assert notify_queue.peek_count() == 0 + + +class TestDebounceGate: + """notify_waiting 的 debounce leading-edge 合流。""" + + def test_debounce_disabled_sends_every_batch(self, _hermetic_queue: None) -> None: + """debounce=0(預設)→ 行為與現況逐一致:每批各自發,不進 queue。""" + with ( + patch("ring.notify.get_config", return_value=Config(notify_debounce_seconds=0)), + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_waiting([_s("a")]) + notify_waiting([_s("b")]) + assert mock_run.call_count == 2 + assert notify_queue.peek_count() == 0 + + def test_first_batch_in_window_sends_leading_edge(self, _hermetic_queue: None) -> None: + with ( + patch("ring.notify.get_config", return_value=Config(notify_debounce_seconds=30)), + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_waiting([_s("a")]) + mock_run.assert_called_once() + + def test_second_batch_within_window_is_enqueued_not_sent(self, _hermetic_queue: None) -> None: + with ( + patch("ring.notify.get_config", return_value=Config(notify_debounce_seconds=30)), + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_waiting([_s("a")]) + notify_waiting([_s("b")]) + mock_run.assert_called_once() # 只有第一批(leading edge)真的發了 + assert notify_queue.peek_count() == 1 # 第二批進 queue + + def test_batch_after_window_expires_sends_leading_edge_again(self, _hermetic_queue: None) -> None: + with ( + patch("ring.notify.get_config", return_value=Config(notify_debounce_seconds=1)), + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_waiting([_s("a")]) + with patch("time.time", return_value=time.time() + 2): + notify_waiting([_s("b")]) + assert mock_run.call_count == 2 # 視窗過期後新的一批又是 leading edge + + +class TestNotifySummary: + def test_zero_or_negative_count_sends_nothing(self) -> None: + with ( + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + notify_summary(0, _s("a")) + mock_run.assert_not_called() + + def test_sends_exactly_one_notification(self) -> None: + with ( + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_summary(3, _s("a", "proj")) + mock_run.assert_called_once() + + def test_message_contains_count(self) -> None: + with ( + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_summary(4, _s("a", "proj")) + args = mock_run.call_args[0][0] + message_val = args[args.index("-message") + 1] + assert "4" in message_val + + def test_marks_summary_but_keeps_real_session_id(self) -> None: + """實際送給後端的 Session 標了 is_summary=True 走彙總句式,但 session_id 維持真實值 + (不覆寫成 sentinel)——點擊通知才能正確 focus 回那個仍在等你的 session, + 不會像舊版 sentinel id 那樣找不到 session 而靜默失敗。""" + with ( + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("ring.notify.terminal_notifier._ring_executable", return_value="/opt/ring/bin/ring"), + patch("ring.notify.terminal_notifier.notify_message") as mock_msg, + patch("ring.notify.terminal_notifier.notify_title") as mock_title, + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + mock_msg.return_value = "x" + mock_title.return_value = "y" + notify_summary(2, _s("real-session-id", "proj")) + sent_session = mock_msg.call_args[0][0] + assert is_summary_session(sent_session) + assert sent_session.session_id == "real-session-id" # 沒被覆寫成 sentinel + # -execute 也是拿真實 session_id 組的,點擊通知會確實嘗試 focus 這個 session。 + args = mock_run.call_args[0][0] + assert args[args.index("-execute") + 1] == "/opt/ring/bin/ring focus real-session-id" + + def test_no_backend_available_does_not_raise(self) -> None: + with patch("shutil.which", return_value=None): + notify_summary(2, _s("a")) # 不應拋 + + def test_backend_none_sends_nothing(self) -> None: + with ( + patch("ring.notify.get_config", return_value=Config(notify_backend="none")), + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("subprocess.run") as mock_run, + ): + notify_summary(2, _s("a")) + mock_run.assert_not_called() + + def test_ring_focus_resolves_summary_session_to_real_session(self) -> None: + """`ring focus` 收到彙總通知帶出來的 session_id,要能正確解析到那個真實 session + (不是像舊版 sentinel id 那樣「找不到 session」)——這是 #4 要修的行為本身,不只是 + 「Session 物件標記對不對」。""" + from ring.commands.focus import run_focus + + real_session = _s("real-session-id", "proj") + with ( + patch("shutil.which", return_value="/usr/local/bin/terminal-notifier"), + patch("ring.notify.terminal_notifier._ring_executable", return_value="/opt/ring/bin/ring"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + notify_summary(3, real_session) + args = mock_run.call_args[0][0] + focused_id = args[args.index("-execute") + 1].rsplit(" ", 1)[-1] + assert focused_id == "real-session-id" + + # 通知帶出來的 session_id 送進 `ring focus`,要能正確解析回這個真實 session + # (模擬點擊通知時的真實路徑:get_by_id 找到它、TUI 在跑 → write_focus_request)。 + with ( + patch("ring.sources.get_by_id", return_value=real_session), + patch("ring.ipc.read_tui_presence", return_value={"tty": "", "pid": 1, "ts": 0.0}), + patch("ring.ipc.write_focus_request") as mock_write_request, + ): + rc = run_focus([focused_id]) + assert rc == 0 + mock_write_request.assert_called_once_with("real-session-id") diff --git a/tests/test_notify_queue.py b/tests/test_notify_queue.py new file mode 100644 index 0000000..a3573a2 --- /dev/null +++ b/tests/test_notify_queue.py @@ -0,0 +1,327 @@ +"""notify_queue.py 單元測試——debounce queue、視窗狀態、quiet 狀態、flush_if_due。""" + +from __future__ import annotations + +import json +import threading +import time +from pathlib import Path +from unittest.mock import patch + +from ring.config import Config +from ring.notify_queue import ( + clear_quiet, + enqueue, + flush_if_due, + format_remaining, + peek_count, + pop_all, + quiet_active, + quiet_remaining, + set_quiet, + try_claim_leading_edge, +) +from ring.registry import Session, Status + + +def _s(sid: str, project: str = "proj") -> Session: + return Session(sid, f"/x/{project}", Status.WAITING, 0.0, "→ Edit", "hook", origin_cwd=f"/x/{project}") + + +# --------------------------------------------------------------------------- enqueue / pop_all / peek_count + + +class TestQueue: + def test_enqueue_dedupes_by_session_id(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + enqueue([_s("a", "p1")], queue_path=q) + enqueue([_s("a", "p1"), _s("b", "p2")], queue_path=q) + result = pop_all(queue_path=q) + assert {s.session_id for s in result} == {"a", "b"} + + def test_pop_all_consumes_queue(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + enqueue([_s("a")], queue_path=q) + first = pop_all(queue_path=q) + second = pop_all(queue_path=q) + assert len(first) == 1 + assert second == [] + + def test_peek_count_does_not_clear(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + enqueue([_s("a"), _s("b")], queue_path=q) + assert peek_count(queue_path=q) == 2 + assert peek_count(queue_path=q) == 2 # 唯讀,不清空 + + def test_peek_count_missing_file_is_zero(self, tmp_path: Path) -> None: + assert peek_count(queue_path=tmp_path / "nope.json") == 0 + + def test_enqueue_empty_list_is_noop(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + enqueue([], queue_path=q) + assert not q.exists() + + def test_pop_all_missing_file_returns_empty(self, tmp_path: Path) -> None: + assert pop_all(queue_path=tmp_path / "nope.json") == [] + + def test_enqueue_survives_corrupt_file(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + q.write_text("not json", encoding="utf-8") + enqueue([_s("a")], queue_path=q) + assert peek_count(queue_path=q) == 1 + + def test_round_trip_preserves_status_and_project(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + enqueue([_s("a", "maigo")], queue_path=q) + result = pop_all(queue_path=q) + assert result[0].status is Status.WAITING + assert result[0].project == "maigo" + + def test_concurrent_enqueue_does_not_lose_updates(self, tmp_path: Path) -> None: + """兩個 thread 同時 enqueue 不同 session → flock 保護,兩筆都要進 queue(不 lost-update)。""" + q = tmp_path / "queue.json" + + def _enqueue_many(prefix: str) -> None: + for i in range(20): + enqueue([_s(f"{prefix}-{i}")], queue_path=q) + + threads = [threading.Thread(target=_enqueue_many, args=(p,)) for p in ("a", "b")] + for t in threads: + t.start() + for t in threads: + t.join() + + result = pop_all(queue_path=q) + assert len(result) == 40 + + +# --------------------------------------------------------------------------- debounce 視窗 + + +class TestTryClaimLeadingEdge: + def test_first_call_claims_leading_edge(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + now = 1000.0 + assert try_claim_leading_edge(now, 30, queue_path=q) is True + + def test_second_call_within_window_does_not_claim(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + now = 1000.0 + try_claim_leading_edge(now, 30, queue_path=q) + assert try_claim_leading_edge(now + 5, 30, queue_path=q) is False + + def test_call_after_window_expires_claims_again(self, tmp_path: Path) -> None: + q = tmp_path / "queue.json" + now = 1000.0 + try_claim_leading_edge(now, 30, queue_path=q) + assert try_claim_leading_edge(now + 31, 30, queue_path=q) is True + + def test_concurrent_calls_only_one_claims_leading_edge(self, tmp_path: Path) -> None: + """Repro 對照組:Soyo 的 race_repro.py 打的是舊版 window_open()+open_window() 兩段式, + 兩個 thread 都會判定自己是 leading edge(count==2)。這裡改打真正的 + try_claim_leading_edge(),同一個鎖區塊內完成判斷+宣告,count 必須是 1。""" + q = tmp_path / "queue.json" + now = time.time() + barrier = threading.Barrier(8) + claimed: list[bool] = [] + lock = threading.Lock() + + def worker() -> None: + barrier.wait() # 逼所有 thread 幾乎同時撞進 try_claim_leading_edge + result = try_claim_leading_edge(now, 30, queue_path=q) + with lock: + claimed.append(result) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + leading_edge_count = sum(1 for c in claimed if c) + assert leading_edge_count == 1 + + +# --------------------------------------------------------------------------- quiet + + +class TestQuiet: + def test_not_active_when_never_set(self, tmp_path: Path) -> None: + assert quiet_active(time.time(), quiet_path=tmp_path / "quiet") is False + + def test_active_with_no_expiry(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + set_quiet(None, quiet_path=p) + assert quiet_active(time.time(), quiet_path=p) is True + + def test_active_before_expiry(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + now = 1000.0 + set_quiet(now + 60, quiet_path=p) + assert quiet_active(now + 30, quiet_path=p) is True + + def test_inactive_after_expiry(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + now = 1000.0 + set_quiet(now - 1, quiet_path=p) + assert quiet_active(now, quiet_path=p) is False + + def test_expired_quiet_file_is_cleaned_up(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + now = 1000.0 + set_quiet(now - 1, quiet_path=p) + quiet_active(now, quiet_path=p) + assert not p.exists() + + def test_clear_quiet_removes_file(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + set_quiet(None, quiet_path=p) + clear_quiet(quiet_path=p) + assert quiet_active(time.time(), quiet_path=p) is False + + def test_clear_quiet_nonexistent_does_not_raise(self, tmp_path: Path) -> None: + clear_quiet(quiet_path=tmp_path / "nope") # 不應拋 + + def test_quiet_remaining_with_expiry(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + now = 1000.0 + set_quiet(now + 90, quiet_path=p) + remaining = quiet_remaining(now, quiet_path=p) + assert remaining is not None + assert 89 < remaining <= 90 + + def test_quiet_remaining_none_when_indefinite(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + set_quiet(None, quiet_path=p) + assert quiet_remaining(time.time(), quiet_path=p) is None + + def test_quiet_remaining_none_when_not_active(self, tmp_path: Path) -> None: + assert quiet_remaining(time.time(), quiet_path=tmp_path / "nope") is None + + def test_set_quiet_writes_since(self, tmp_path: Path) -> None: + p = tmp_path / "quiet" + set_quiet(None, quiet_path=p) + data = json.loads(p.read_text(encoding="utf-8")) + assert "since" in data + + +class TestFormatRemaining: + def test_seconds(self) -> None: + assert "30" in format_remaining(30) + + def test_minutes(self) -> None: + assert "12" in format_remaining(12 * 60 + 5) + + def test_hours(self) -> None: + assert "2" in format_remaining(2 * 3600 + 100) + + def test_negative_clamped_to_zero(self) -> None: + format_remaining(-5) # 不應拋 + + +# --------------------------------------------------------------------------- flush_if_due + + +class TestFlushIfDue: + def _cfg(self, debounce: int) -> Config: + return Config(notify_debounce_seconds=debounce) + + def test_no_flush_when_quiet_active(self, tmp_path: Path) -> None: + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + enqueue([_s("a")], queue_path=q) + set_quiet(None, quiet_path=quiet) + with patch("ring.notify_queue.get_config", return_value=self._cfg(5)): + with patch("ring.notify.notify_summary") as mock_summary: + flush_if_due(now=time.time(), queue_path=q, quiet_path=quiet) + mock_summary.assert_not_called() + assert peek_count(queue_path=q) == 1 # queue 保留 + + def test_no_flush_within_debounce_window(self, tmp_path: Path) -> None: + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + now = 1000.0 + try_claim_leading_edge(now, 1, queue_path=q) # 開視窗(seconds 值在此無關緊要,只是設置起點) + enqueue([_s("a")], queue_path=q) + with patch("ring.notify_queue.get_config", return_value=self._cfg(30)): + with patch("ring.notify.notify_summary") as mock_summary: + flush_if_due(now=now + 5, queue_path=q, quiet_path=quiet) + mock_summary.assert_not_called() + + def test_flushes_after_window_expires(self, tmp_path: Path) -> None: + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + now = 1000.0 + try_claim_leading_edge(now, 1, queue_path=q) # 開視窗(seconds 值在此無關緊要,只是設置起點) + enqueue([_s("a"), _s("b")], queue_path=q) + with patch("ring.notify_queue.get_config", return_value=self._cfg(10)): + with patch("ring.notify.notify_summary") as mock_summary: + flush_if_due(now=now + 11, queue_path=q, quiet_path=quiet) + mock_summary.assert_called_once() + assert mock_summary.call_args[0][0] == 2 + assert peek_count(queue_path=q) == 0 + + def test_flushes_with_debounce_disabled_no_window_ever_opened(self, tmp_path: Path) -> None: + """debounce=0(純 quiet 累積的殘留)——沒開過視窗也要能被懶惰 flush。""" + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + enqueue([_s("a")], queue_path=q) + with patch("ring.notify_queue.get_config", return_value=self._cfg(0)): + with patch("ring.notify.notify_summary") as mock_summary: + flush_if_due(now=time.time(), queue_path=q, quiet_path=quiet) + mock_summary.assert_called_once() + + def test_no_flush_when_queue_empty(self, tmp_path: Path) -> None: + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + with patch("ring.notify_queue.get_config", return_value=self._cfg(0)): + with patch("ring.notify.notify_summary") as mock_summary: + flush_if_due(now=time.time(), queue_path=q, quiet_path=quiet) + mock_summary.assert_not_called() + + def test_force_bypasses_quiet_and_window(self, tmp_path: Path) -> None: + """``ring quiet off`` 呼叫的 force=True:跳過 quiet/視窗判斷,queue 有東西就 flush。""" + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + now = 1000.0 + try_claim_leading_edge(now, 1, queue_path=q) # 開視窗(seconds 值在此無關緊要,只是設置起點) + enqueue([_s("a")], queue_path=q) + set_quiet(None, quiet_path=quiet) + with patch("ring.notify_queue.get_config", return_value=self._cfg(999)): + with patch("ring.notify.notify_summary") as mock_summary: + flush_if_due(now=now + 1, force=True, queue_path=q, quiet_path=quiet) + mock_summary.assert_called_once() + assert peek_count(queue_path=q) == 0 + + def test_flush_failure_is_swallowed(self, tmp_path: Path) -> None: + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + enqueue([_s("a")], queue_path=q) + with patch("ring.notify_queue.get_config", return_value=self._cfg(0)): + with patch("ring.notify.notify_summary", side_effect=Exception("boom")): + flush_if_due(now=time.time(), queue_path=q, quiet_path=quiet) # 不應拋 + + def test_concurrent_flush_pops_exactly_once(self, tmp_path: Path) -> None: + """並發 flush(check-該不該-then-pop-then-清視窗 併進同一鎖區塊):多個 thread + 同時對一個已過期視窗呼叫 flush_if_due,彙總只能被觸發一次、session 只被 pop 一次 + ——不是像舊版拆成多次 _locked() 那樣可能被重複 pop 或把別人剛開的新視窗清掉。""" + q, quiet = tmp_path / "queue.json", tmp_path / "quiet" + now = 1000.0 + try_claim_leading_edge(now, 1, queue_path=q) # 開視窗(seconds 值在此無關緊要,只是設置起點) + enqueue([_s(f"s{i}") for i in range(5)], queue_path=q) + + summary_calls: list[int] = [] + lock = threading.Lock() + + def worker() -> None: + flush_if_due(now=now + 11, queue_path=q, quiet_path=quiet) + + def fake_notify_summary(count: int, sample: object) -> None: + with lock: + summary_calls.append(count) + + with ( + patch("ring.notify_queue.get_config", return_value=self._cfg(10)), + patch("ring.notify.notify_summary", side_effect=fake_notify_summary), + ): + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert summary_calls == [5] # 只有一個 thread 真的 pop 到、發了一次彙總,其餘拿到空 queue + assert peek_count(queue_path=q) == 0 diff --git a/tests/test_tui.py b/tests/test_tui.py index c62acdf..9d5e417 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -16,6 +16,14 @@ def _silence_notifications(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tui.RingApp, "bell", lambda self: None, raising=False) +@pytest.fixture(autouse=True) +def _hermetic_notify_queue(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """把 debounce queue/quiet 狀態檔導去 tmp_path,避免 _reload 的 flush_if_due / + _quiet_badge 碰到機器上真實的狀態檔。""" + monkeypatch.setattr("ring.notify_queue._QUEUE_PATH", tmp_path / "notify-queue.json") + monkeypatch.setattr("ring.notify_queue._QUIET_PATH", tmp_path / "quiet") + + @pytest.mark.asyncio async def test_tui_mounts_and_lists_sessions(monkeypatch: pytest.MonkeyPatch) -> None: sessions = [ @@ -69,6 +77,99 @@ async def test_tui_jump_without_tmux_target_does_not_crash(monkeypatch: pytest.M assert app.query_one(DataTable).row_count == 1 +@pytest.mark.asyncio +async def test_quiet_active_suppresses_bell(monkeypatch: pytest.MonkeyPatch) -> None: + """quiet active 時,新轉 waiting 也不該響 in-app 鈴(quiet 說到做到)。""" + import ring.notify_queue as notify_queue + + state: dict[str, list[Session]] = {"sessions": [Session("a", "/x/p", Status.WORKING, 0.0, "-", "scan")]} + monkeypatch.setattr(tui, "board", lambda show_all: state["sessions"]) + monkeypatch.setattr(tui, "running_agent_pids", lambda: [1]) + + app = tui.RingApp(lang="en") + async with app.run_test(): + notify_queue.set_quiet(None) + bells: list[int] = [] + monkeypatch.setattr(app, "bell", lambda: bells.append(1)) + state["sessions"] = [Session("a", "/x/p", Status.WAITING, 0.0, "-", "scan")] # WORKING → WAITING + app._reload() + assert bells == [] + + +@pytest.mark.asyncio +async def test_quiet_active_enqueues_codex_promoted_alert_instead_of_dropping( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """#1 修復證據:quiet active 時 codex 核可等待(讀取側逾時判定,走 notify_waiting(promoted) + 才會 enqueue)不能被永久漏掉——`notify_waiting(promoted)` 必須先於 quiet 的 early return + 被呼叫,它自己的 quiet gate 才會把這個 session enqueue,quiet 解除後才補得回來。 + 這裡刻意不 mock `ring.notify.notify_waiting`,讓它真的走到 notify_queue.enqueue()。""" + import ring.notify_queue as notify_queue + + state: dict[str, list[Session]] = { + "sessions": [Session("codex:t1", "/x/p", Status.WORKING, 0.0, "-", "hook", provider="codex")] + } + monkeypatch.setattr(tui, "board", lambda show_all: state["sessions"]) + monkeypatch.setattr(tui, "running_agent_pids", lambda: [1]) + monkeypatch.setattr("shutil.which", lambda _name: None) # 沒有真的通知後端可用,不會噴通知 + + app = tui.RingApp(lang="en") + async with app.run_test(): + notify_queue.set_quiet(None) + assert notify_queue.peek_count() == 0 + + state["sessions"] = [ + Session( + "codex:t1", + "/x/p", + Status.WAITING, + 0.0, + "-", + "hook", + provider="codex", + waiting_kind="permission", + ) + ] + app._reload() + + assert notify_queue.peek_count() == 1 # 入隊了,不是被丟掉 + + # quiet 解除後,之前入隊的 codex 核可等待要能被 flush 補發彙總(而非永久消失)。 + summary_calls: list[int] = [] + monkeypatch.setattr("ring.notify.notify_summary", lambda count, sample: summary_calls.append(count)) + notify_queue.clear_quiet() + app._reload() + assert summary_calls == [1] + assert notify_queue.peek_count() == 0 + + +@pytest.mark.asyncio +async def test_header_badge_shows_quiet_and_queue_count(monkeypatch: pytest.MonkeyPatch) -> None: + import ring.notify_queue as notify_queue + + # clear_quiet 之後的下一次 _reload 會懶惰 flush(見 notify_queue.flush_if_due); + # 擋掉真正的送出,這個測試只關心 badge 文字,不該真的打通知後端。 + monkeypatch.setattr("ring.notify.notify_summary", lambda count, sample: None) + sessions = [Session("a", "/x/maigo", Status.WORKING, 0.0, "hi", "scan")] + monkeypatch.setattr(tui, "board", lambda show_all: sessions) + monkeypatch.setattr(tui, "running_agent_pids", lambda: [1]) + + app = tui.RingApp(lang="en") + async with app.run_test(): + assert "QUIET" not in app.sub_title + assert "queue:" not in app.sub_title + + notify_queue.set_quiet(None) + notify_queue.enqueue([Session("b", "/y/p", Status.WAITING, 0.0, "-", "hook")]) + app._reload() + assert "QUIET" in app.sub_title + assert "queue: 1" in app.sub_title + + notify_queue.clear_quiet() + app._reload() + assert "QUIET" not in app.sub_title + + @pytest.mark.asyncio async def test_new_waiting_rings_bell(monkeypatch: pytest.MonkeyPatch) -> None: state: dict[str, list[Session]] = {"sessions": [Session("a", "/x/p", Status.WORKING, 0.0, "-", "scan")]} From e701c2c7d43106345dde44e47fba4c73bcab83d1 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Tue, 21 Jul 2026 08:12:07 +0800 Subject: [PATCH 2/2] feat: surface background-agent input requests as waiting Claude Code emits a Notification with notification_type=agent_needs_input when a background agent stops to ask the user for input. RiNG treated it as idle because the type was not in the action-required set, so the board never flagged it red. Add it (waiting_kind=question) so the session surfaces as waiting-for-you. Also document a known limitation in _promote_codex_permission_wait: some undocumented Claude Code UI guardrails (e.g. the multiple-cd Bash approval) block without firing any hook, so RiNG cannot detect them. The silence-timeout heuristic is deliberately not extended to claude-code because such a state is indistinguishable from normal post-turn idle. --- src/ring/hook_protocol.py | 6 +++++- src/ring/registry.py | 12 ++++++++++-- tests/test_hook.py | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/ring/hook_protocol.py b/src/ring/hook_protocol.py index 8139c81..2234386 100644 --- a/src/ring/hook_protocol.py +++ b/src/ring/hook_protocol.py @@ -47,8 +47,9 @@ class NormalizedHookEvent: } _ACTION_REQUIRED_NOTIFICATION_TYPES = { - "permission_prompt", + "agent_needs_input", # 背景 agent 停下來要使用者輸入(claude 2.1.215 binary 實證有此型別) "elicitation_dialog", + "permission_prompt", } _ACTION_REQUIRED_WAITING_FOR = { @@ -245,6 +246,9 @@ def _waiting_kind(data: Mapping[str, Any], event: str, status: Status) -> str: # 先於 PermissionRequest 判斷:AskUserQuestion 也會以 PermissionRequest 事件 # 進來(權限判定包著問題),但使用者要回的是「問題」不是「權限」。 return "question" + if notification_type == "agent_needs_input": + # 背景 agent 要使用者輸入,性質是「回答」而非權限核可 → ❓ 而非 🔐。 + return "question" if event == "PermissionRequest" or notification_type == "permission_prompt": return "permission" if waiting_for in {"approval", "permission"}: diff --git a/src/ring/registry.py b/src/ring/registry.py index 2714d58..c85b31e 100644 --- a/src/ring/registry.py +++ b/src/ring/registry.py @@ -384,8 +384,16 @@ def _promote_codex_permission_wait( 訊號——這是對 hook 資料的推遲判定,不是 scan 猜測。任何後續 hook 事件會覆寫 last_event,自然清紅。 - 只對 codex 啟用:claude-code 真的停下來等人時會補發 permission_prompt Notification - (hook 直接標 🔴),不需要、也不該重複走這條路。純函式、可單測。 + 只對 codex 啟用:claude-code 真的停下來等人時「通常」會補發 permission_prompt + Notification(hook 直接標 🔴),不需要、也不該重複走這條路。純函式、可單測。 + + 已知例外(2026-07-20 現場取證):claude-code 少數未記載的 UI guardrail——例如 + 「Multiple directory changes in one command require approval」(單一 Bash 指令內 + 含多個 cd)——阻塞等核可時**完全不 fire 任何 hook**(PreToolUse / PermissionRequest + / permission_prompt 皆無,raw payload log 掛零可證)。這類提示走在 hook lifecycle + 之外,RiNG 無法可靠偵測;官方也無非-hook 機制可查詢阻塞狀態。此限制屬上游,非本函式 + 可補——刻意不把靜默逾時判定擴到 claude-code,因為它連 idle_prompt 都送、與正常閒置 + 無法區分,擴了只會誤報。 """ return ( provider == "codex" diff --git a/tests/test_hook.py b/tests/test_hook.py index 191eaad..1538aed 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -156,6 +156,24 @@ def test_permission_notification_writes_waiting(monkeypatch: pytest.MonkeyPatch, assert data["status"] == Status.WAITING.value +def test_agent_needs_input_notification_writes_waiting(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """背景 agent 停下來要輸入的 Notification → 🔴 等你(question 類),不是 🟡 閒置。 + + 型別實證:claude 2.1.215 binary 含 ``agent_needs_input`` 字串。 + """ + monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) + _feed( + monkeypatch, + {"session_id": "s1", "hook_event_name": "Notification", "notification_type": "agent_needs_input", "cwd": "/x"}, + ) + + assert hook.run_hook() == 0 + + data = json.loads((tmp_path / "s1.json").read_text()) + assert data["status"] == Status.WAITING.value + assert data["waiting_kind"] == "question" + + def test_regular_notification_writes_idle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(hook, "RING_REGISTRY", tmp_path) _feed(