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/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/locale/en/LC_MESSAGES/ring.mo b/src/ring/locale/en/LC_MESSAGES/ring.mo index 4e26bf3..0fb6ee7 100644 Binary files a/src/ring/locale/en/LC_MESSAGES/ring.mo and b/src/ring/locale/en/LC_MESSAGES/ring.mo differ diff --git a/src/ring/locale/en/LC_MESSAGES/ring.po b/src/ring/locale/en/LC_MESSAGES/ring.po index 42cf23f..9bc62b7 100644 --- a/src/ring/locale/en/LC_MESSAGES/ring.po +++ b/src/ring/locale/en/LC_MESSAGES/ring.po @@ -239,6 +239,9 @@ msgstr "Change with `ring config set KEY VALUE`, or edit the file above; see the msgid "未知的鍵:{key}" msgstr "unknown key: {key}" +msgid "⚠️ {path} 有未知的設定鍵(可能打錯字,已忽略):{keys}" +msgstr "⚠️ {path} has unknown config keys (likely a typo, ignored): {keys}" + msgid "用法:ring config get KEY" msgstr "usage: ring config get KEY" @@ -265,6 +268,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" @@ -281,6 +288,10 @@ msgstr "" " config show config file path and current settings\n" " config get KEY read one setting's current value\n" " config set KEY VALUE write one setting (rewrites config file, comments lost)\n" +" quiet show quiet (temporary global mute) status\n" +" quiet on turn quiet on (stays muted until manually cleared)\n" +" quiet off turn quiet off (flushes any coalesced summary right away)\n" +" quiet DURATION turn quiet on for a while (e.g. 30m, 1h), auto-clears on expiry\n" " focus SESSION_ID focus a session; if TUI is running, returns to RiNG and selects the row\n" " gc [--dry-run] clean RiNG's own stale state files\n" " doctor show environment diagnosis (read-only) — hooks, notify, focuser, maintenance hints\n" @@ -329,6 +340,26 @@ msgstr "" " get KEY print the current value of one setting (use colors. 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..c85b31e 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: @@ -380,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/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..1538aed 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: @@ -152,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( @@ -300,6 +322,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")]}