ring-cli 0.13.0__tar.gz → 0.14.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. ring_cli-0.14.1/PKG-INFO +96 -0
  2. ring_cli-0.14.1/README.en.md +70 -0
  3. {ring_cli-0.13.0 → ring_cli-0.14.1}/pyproject.toml +15 -7
  4. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/cli.py +37 -0
  5. ring_cli-0.14.1/src/ring/commands/quiet.py +54 -0
  6. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/config.py +29 -3
  7. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/focus/__init__.py +29 -8
  8. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/focus/applescript.py +8 -0
  9. ring_cli-0.14.1/src/ring/focus/iterm2.py +82 -0
  10. ring_cli-0.14.1/src/ring/focus/trace.py +79 -0
  11. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/hook.py +11 -0
  12. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/hook_protocol.py +5 -1
  13. ring_cli-0.14.1/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  14. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/locale/en/LC_MESSAGES/ring.po +86 -0
  15. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/locale/ring.pot +203 -106
  16. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/__init__.py +61 -1
  17. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/base.py +18 -1
  18. ring_cli-0.14.1/src/ring/notify_queue.py +332 -0
  19. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/registry.py +15 -3
  20. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/tui.py +39 -4
  21. ring_cli-0.13.0/PKG-INFO +0 -514
  22. ring_cli-0.13.0/README.en.md +0 -489
  23. ring_cli-0.13.0/src/ring/focus/iterm2.py +0 -28
  24. ring_cli-0.13.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  25. {ring_cli-0.13.0 → ring_cli-0.14.1}/LICENSE +0 -0
  26. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/__init__.py +0 -0
  27. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/__main__.py +0 -0
  28. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/__init__.py +0 -0
  29. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/_args.py +0 -0
  30. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/completion.py +0 -0
  31. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/digest.py +0 -0
  32. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/doctor.py +0 -0
  33. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/focus.py +0 -0
  34. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/gc.py +0 -0
  35. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/hook.py +0 -0
  36. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/commands/stats.py +0 -0
  37. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/focus/base.py +0 -0
  38. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/focus/linux_wm.py +0 -0
  39. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/focus/neovim.py +0 -0
  40. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/focus/terminal.py +0 -0
  41. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/focus/tmux.py +0 -0
  42. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/gc.py +0 -0
  43. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/i18n.py +0 -0
  44. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/ipc.py +0 -0
  45. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/labels.py +0 -0
  46. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/command.py +0 -0
  47. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/notify_send.py +0 -0
  48. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/ntfy.py +0 -0
  49. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/osascript_notifier.py +0 -0
  50. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/terminal_notifier.py +0 -0
  51. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/notify/webhook.py +0 -0
  52. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/osascript.py +0 -0
  53. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/payload_log.py +0 -0
  54. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/permission.py +0 -0
  55. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/plugins.py +0 -0
  56. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/question_detect.py +0 -0
  57. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/sources/__init__.py +0 -0
  58. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/sources/base.py +0 -0
  59. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/sources/claude_code.py +0 -0
  60. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/sources/codex.py +0 -0
  61. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/sources/hook_registry.py +0 -0
  62. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/sources/local_llm.py +0 -0
  63. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/stats.py +0 -0
  64. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/transcript.py +0 -0
  65. {ring_cli-0.13.0 → ring_cli-0.14.1}/src/ring/watcher.py +0 -0
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: ring-cli
3
+ Version: 0.14.1
4
+ Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
5
+ Keywords: claude-code,codex,ollama,llama-cpp,tui,dashboard,session,monitor,rich
6
+ Author: Wei Lee
7
+ Author-email: Wei Lee <weilee.rx@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development
17
+ Classifier: Topic :: Utilities
18
+ Requires-Dist: rich>=13
19
+ Requires-Dist: textual>=0.80 ; extra == 'tui'
20
+ Requires-Python: >=3.13
21
+ Project-URL: Homepage, https://github.com/Lee-W/ring
22
+ Project-URL: Repository, https://github.com/Lee-W/ring
23
+ Project-URL: Documentation, https://lee-w.github.io/ring/
24
+ Provides-Extra: tui
25
+ Description-Content-Type: text/markdown
26
+
27
+ # RiNG 🎤
28
+
29
+ [台灣華語](README.md) · **English**
30
+
31
+ [![PyPI](https://img.shields.io/pypi/v/ring-cli?label=PyPI)](https://pypi.org/project/ring-cli/)
32
+ [![Python](https://img.shields.io/pypi/pyversions/ring-cli)](https://pypi.org/project/ring-cli/)
33
+ [![License](https://img.shields.io/pypi/l/ring-cli)](LICENSE)
34
+
35
+ > **R**ealtime **I**nstance **N**otification **G**rid
36
+ > — one local board for all active agent-CLI sessions.
37
+
38
+ When several Claude Code, Codex, or local-model sessions are running, RiNG shows which ones are working, idle, or waiting for you. Sessions that need a response are sorted first.
39
+
40
+ ```text
41
+ 🎤 RiNG — 3 sessions on stage · 2 agent processes running
42
+
43
+ 🔴 maigo 12s → waiting for permission
44
+ 🟢 pelican-osm 3s → Edit
45
+ 🟡 commitizen 8m turn finished, idle
46
+ ```
47
+
48
+ ## What It Does
49
+
50
+ - **One board for session state**: built-in sources for Claude Code, Codex, Ollama, and llama.cpp.
51
+ - **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
52
+ - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
53
+ - **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
54
+ - **Fits existing workflows**: status-bar output, JSON, a provider-neutral hook, and plugin extension points.
55
+
56
+ ## Get Started in Three Steps
57
+
58
+ Requires Python 3.13+:
59
+
60
+ ```sh
61
+ uv tool install 'ring-cli[tui]'
62
+ ring --watch
63
+ ring install-hooks
64
+ ```
65
+
66
+ 1. `ring --watch` opens the interactive board. Select a session and press `Enter` to return to its terminal.
67
+ 2. `ring install-hooks` installs Claude Code and Codex hooks for precise 🔴 waiting states and system notifications.
68
+ 3. Restart agent sessions after installing hooks. RiNG can then notify you even while its board is closed.
69
+
70
+ Use `pipx install 'ring-cli[tui]'` if you prefer pipx. Run `ring` for a one-shot snapshot.
71
+
72
+ ## Common Operations
73
+
74
+ | Operation | Purpose |
75
+ |-----------|---------|
76
+ | `ring` | Print a snapshot of every current session |
77
+ | `ring --watch` | Open the continuously updating interactive TUI |
78
+ | `Enter` / `Space` | Return to the selected session's terminal |
79
+ | `p` | Reply to the selected session's permission request in place |
80
+ | `n` | Name the selected session |
81
+ | `ring doctor` | Check hooks, notifications, and terminal focus support |
82
+ | `ring --format oneline` | Produce a summary for tmux, SwiftBar, or waybar |
83
+
84
+ Zero-config mode works without hooks. Precise 🔴 waiting states, pending details, and immediate notifications require hooks. Restart Claude Code or Codex sessions after installing or updating them.
85
+
86
+ ## Learn More
87
+
88
+ - [Full guide](https://lee-w.github.io/ring/guide/): commands, TUI controls, hooks, notifications, configuration, extensions, and privacy
89
+ - [Session states](https://lee-w.github.io/ring/session-states/): how RiNG derives 🔴/🟢/🟡/⚫
90
+ - [Contributing guide](CONTRIBUTING.md): development setup, tests, and PR checklist
91
+
92
+ RiNG supports macOS and Linux; Windows is not yet supported. By default it only reads local agent data and writes `~/.config/ring/`. Network access occurs only for ntfy or webhook notifiers you configure.
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,70 @@
1
+ # RiNG 🎤
2
+
3
+ [台灣華語](README.md) · **English**
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/ring-cli?label=PyPI)](https://pypi.org/project/ring-cli/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/ring-cli)](https://pypi.org/project/ring-cli/)
7
+ [![License](https://img.shields.io/pypi/l/ring-cli)](LICENSE)
8
+
9
+ > **R**ealtime **I**nstance **N**otification **G**rid
10
+ > — one local board for all active agent-CLI sessions.
11
+
12
+ When several Claude Code, Codex, or local-model sessions are running, RiNG shows which ones are working, idle, or waiting for you. Sessions that need a response are sorted first.
13
+
14
+ ```text
15
+ 🎤 RiNG — 3 sessions on stage · 2 agent processes running
16
+
17
+ 🔴 maigo 12s → waiting for permission
18
+ 🟢 pelican-osm 3s → Edit
19
+ 🟡 commitizen 8m turn finished, idle
20
+ ```
21
+
22
+ ## What It Does
23
+
24
+ - **One board for session state**: built-in sources for Claude Code, Codex, Ollama, and llama.cpp.
25
+ - **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
26
+ - **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
27
+ - **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
28
+ - **Fits existing workflows**: status-bar output, JSON, a provider-neutral hook, and plugin extension points.
29
+
30
+ ## Get Started in Three Steps
31
+
32
+ Requires Python 3.13+:
33
+
34
+ ```sh
35
+ uv tool install 'ring-cli[tui]'
36
+ ring --watch
37
+ ring install-hooks
38
+ ```
39
+
40
+ 1. `ring --watch` opens the interactive board. Select a session and press `Enter` to return to its terminal.
41
+ 2. `ring install-hooks` installs Claude Code and Codex hooks for precise 🔴 waiting states and system notifications.
42
+ 3. Restart agent sessions after installing hooks. RiNG can then notify you even while its board is closed.
43
+
44
+ Use `pipx install 'ring-cli[tui]'` if you prefer pipx. Run `ring` for a one-shot snapshot.
45
+
46
+ ## Common Operations
47
+
48
+ | Operation | Purpose |
49
+ |-----------|---------|
50
+ | `ring` | Print a snapshot of every current session |
51
+ | `ring --watch` | Open the continuously updating interactive TUI |
52
+ | `Enter` / `Space` | Return to the selected session's terminal |
53
+ | `p` | Reply to the selected session's permission request in place |
54
+ | `n` | Name the selected session |
55
+ | `ring doctor` | Check hooks, notifications, and terminal focus support |
56
+ | `ring --format oneline` | Produce a summary for tmux, SwiftBar, or waybar |
57
+
58
+ Zero-config mode works without hooks. Precise 🔴 waiting states, pending details, and immediate notifications require hooks. Restart Claude Code or Codex sessions after installing or updating them.
59
+
60
+ ## Learn More
61
+
62
+ - [Full guide](https://lee-w.github.io/ring/guide/): commands, TUI controls, hooks, notifications, configuration, extensions, and privacy
63
+ - [Session states](https://lee-w.github.io/ring/session-states/): how RiNG derives 🔴/🟢/🟡/⚫
64
+ - [Contributing guide](CONTRIBUTING.md): development setup, tests, and PR checklist
65
+
66
+ RiNG supports macOS and Linux; Windows is not yet supported. By default it only reads local agent data and writes `~/.config/ring/`. Network access occurs only for ntfy or webhook notifiers you configure.
67
+
68
+ ## License
69
+
70
+ MIT
@@ -2,7 +2,7 @@
2
2
  # import 的 module(見 [tool.uv.build-backend] module-name)與 CLI 指令仍是 `ring`。
3
3
  [project]
4
4
  name = "ring-cli"
5
- version = "0.13.0"
5
+ version = "0.14.1"
6
6
  description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
7
7
  readme = "README.en.md"
8
8
  requires-python = ">=3.13"
@@ -35,6 +35,7 @@ dependencies = ["rich>=13"]
35
35
  [project.urls]
36
36
  Homepage = "https://github.com/Lee-W/ring"
37
37
  Repository = "https://github.com/Lee-W/ring"
38
+ Documentation = "https://lee-w.github.io/ring/"
38
39
 
39
40
  [project.optional-dependencies]
40
41
  # live TUI 進階版用 Textual(建在 Rich 之上)。
@@ -58,6 +59,7 @@ test = [
58
59
  "textual>=0.80",
59
60
  ]
60
61
  linters = ["ruff>=0.15.1", "mypy>=1.19.1", "prek>=0.3.3", "commitizen>=4.13.9"]
62
+ docs = ["mkdocs-material>=9,<10", "mkdocs-static-i18n>=1.3,<2"]
61
63
 
62
64
  [build-system]
63
65
  requires = ["uv_build >= 0.10.3, <0.12.0"]
@@ -112,14 +114,20 @@ ci.env = { SKIP = "no-commit-to-branch" }
112
114
  setup-pre-commit.help = "Install pre-commit hooks"
113
115
  setup-pre-commit.cmd = "prek install"
114
116
 
115
- i18n-extract.help = "從原始碼抽出可翻譯字串到 .pot(改字串後重抽。不要用 pybabel update:會塞空 msgstr 進 .po,弄壞 i18n 測試。新字串請手動把已翻譯條目加進各 .po)"
116
- i18n-extract.cmd = "pybabel extract -F babel.cfg -o src/ring/locale/ring.pot src/ring"
117
+ "i18n:extract".help = "從原始碼抽出可翻譯字串到 .pot(改字串後重抽。不要用 pybabel update:會塞空 msgstr 進 .po,弄壞 i18n 測試。新字串請手動把已翻譯條目加進各 .po)"
118
+ "i18n:extract".cmd = "pybabel extract -F babel.cfg -o src/ring/locale/ring.pot src/ring"
117
119
 
118
- i18n-check.help = "列出原始碼有、但某語言 .po 還沒翻譯的字串(純資訊,不失敗)。先跑 i18n-extract"
119
- i18n-check.cmd = "python scripts/i18n_check.py"
120
+ "i18n:check".help = "列出原始碼有、但某語言 .po 還沒翻譯的字串(純資訊,不失敗)。先跑 i18n:extract"
121
+ "i18n:check".cmd = "python scripts/i18n_check.py"
120
122
 
121
- i18n-compile.help = "把各語言的 .po 編成 .mo(改 .po 後要重編並 commit)"
122
- i18n-compile.cmd = "pybabel compile -d src/ring/locale -D ring"
123
+ "i18n:compile".help = "把各語言的 .po 編成 .mo(改 .po 後要重編並 commit)"
124
+ "i18n:compile".cmd = "pybabel compile -d src/ring/locale -D ring"
125
+
126
+ "docs:serve".help = "Serve the docs site locally with live reload"
127
+ "docs:serve".cmd = "mkdocs serve"
128
+
129
+ "docs:build".help = "Build the docs site into site/"
130
+ "docs:build".cmd = "mkdocs build --strict"
123
131
 
124
132
  [tool.commitizen]
125
133
  name = "cz_conventional_commits"
@@ -25,6 +25,7 @@ from ring.commands.doctor import run_doctor
25
25
  from ring.commands.focus import run_focus
26
26
  from ring.commands.gc import run_gc
27
27
  from ring.commands.hook import run_hook_command, run_install_hooks, run_remove_hooks
28
+ from ring.commands.quiet import run_quiet
28
29
  from ring.commands.stats import run_stats
29
30
  from ring.config import CONFIG_PATH as CONFIG_PATH
30
31
  from ring.config import Config as Config
@@ -34,6 +35,7 @@ from ring.config import set_value as set_value
34
35
  from ring.i18n import gettext as _
35
36
  from ring.i18n import ngettext, set_lang
36
37
  from ring.labels import load_labels
38
+ from ring.notify_queue import flush_if_due
37
39
  from ring.plugins import load_plugins
38
40
  from ring.registry import Session, Status, running_agent_pids
39
41
  from ring.sources import discover_sessions
@@ -399,16 +401,32 @@ def run_config(args: list[str]) -> int:
399
401
  return 2
400
402
 
401
403
 
404
+ def _watch_flush_if_due() -> None:
405
+ """headless watch 輪詢時的懶惰 flush 觸發點(同 hook.py / tui.py 既有呼叫慣例)。
406
+
407
+ quiet active 或仍在 debounce 視窗內時,``flush_if_due`` 內部本來就會 skip(跟
408
+ ``test_run_hook_does_not_flush_while_quiet_active`` 同機制),這裡不需另外判斷。
409
+ 失敗安靜吞掉,不影響看板本身。
410
+ """
411
+ try:
412
+ flush_if_due()
413
+ except Exception:
414
+ pass
415
+
416
+
402
417
  def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
403
418
  # 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
404
419
  # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
405
420
  # 例外:codex 核可等待是讀取側的靜默逾時判定,沒有 hook 事件可發通知,由 TUI 的
406
421
  # 提醒排程器代發(tui._ring_on_waiting_alerts);headless watch 仍不發,是已知限制。
422
+ # debounce/quiet 合流 queue 的懶惰 flush:headless watch 沒有 hook 事件也沒有 TUI 輪詢
423
+ # 可以觸發,每輪自己補上一次,否則純 headless 使用者開了 quiet/debounce 通知會無限期卡住。
407
424
  frames = 0
408
425
  footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
409
426
  if not HAVE_RICH:
410
427
  try:
411
428
  while True:
429
+ _watch_flush_if_due()
412
430
  sys.stdout.write("\033[2J\033[H")
413
431
  sessions = board(show_all)
414
432
  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
425
443
  try:
426
444
  with Live(console=console, screen=True, auto_refresh=False) as live:
427
445
  while True:
446
+ _watch_flush_if_due()
428
447
  sessions = board(show_all)
429
448
  body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
430
449
  live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
@@ -457,6 +476,10 @@ commands:
457
476
  config 顯示設定檔位置與目前生效的設定
458
477
  config get KEY 讀單一設定的目前值
459
478
  config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)
479
+ quiet 顯示 quiet(暫時全域靜音)現況
480
+ quiet on 開啟 quiet(手動解除前一直靜音)
481
+ quiet off 解除 quiet(立即補發合流的彙總通知)
482
+ quiet DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除
460
483
  focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
461
484
  gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔
462
485
  doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示
@@ -493,6 +516,17 @@ def _subcommand_help(name: str) -> str:
493
516
  不帶參數:顯示設定檔位置(~/.config/ring/config.toml)與目前生效的所有設定。
494
517
  get KEY 印出單一設定的目前值(colors 子鍵用 colors.<name>)。
495
518
  set KEY VALUE 寫入單一設定。注意:會重寫整個設定檔,原有註解不會保留。
519
+ """
520
+ ),
521
+ "quiet": _(
522
+ """usage: ring quiet [on | off | DURATION]
523
+
524
+ 暫時全域靜音:靜音期間所有轉 🔴 等你的通知都先進 queue,解除時懶惰補發一則彙總。
525
+
526
+ 不帶參數:顯示目前 quiet 現況(開/關、剩餘時間)。
527
+ on 開啟 quiet,手動解除前一直靜音。
528
+ off 解除 quiet,並立即補發合流的彙總通知。
529
+ DURATION 開啟 quiet 一段時間(例如 30m、1h),到期自動解除。
496
530
  """
497
531
  ),
498
532
  "focus": _(
@@ -569,6 +603,7 @@ def main(argv: list[str] | None = None) -> int:
569
603
  "digest",
570
604
  "stats",
571
605
  "completion",
606
+ "quiet",
572
607
  }
573
608
  and any(arg in {"-h", "--help"} for arg in raw[1:])
574
609
  ):
@@ -583,6 +618,8 @@ def main(argv: list[str] | None = None) -> int:
583
618
  return run_remove_hooks(raw[1:])
584
619
  if raw and raw[0] == "config":
585
620
  return run_config(raw[1:])
621
+ if raw and raw[0] == "quiet":
622
+ return run_quiet(raw[1:])
586
623
  if raw and raw[0] == "gc":
587
624
  return run_gc(raw[1:])
588
625
  if raw and raw[0] == "doctor":
@@ -0,0 +1,54 @@
1
+ """``ring quiet`` command handler:暫時全域靜音(跟 debounce 共用同一份 queue/flush 機制)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+
8
+ from ring.commands._args import strip_lang
9
+ from ring.gc import parse_duration
10
+ from ring.i18n import gettext as _
11
+ from ring.notify_queue import clear_quiet, flush_if_due, format_remaining, quiet_active, quiet_remaining, set_quiet
12
+
13
+
14
+ def _print_status(now: float) -> None:
15
+ if not quiet_active(now):
16
+ print(_("🔈 quiet:目前關閉"))
17
+ return
18
+ remaining = quiet_remaining(now)
19
+ if remaining is None:
20
+ print(_("🔇 quiet:開啟中(手動解除前一直靜音)"))
21
+ else:
22
+ print(_("🔇 quiet:開啟中,剩 {remaining}", remaining=format_remaining(remaining)))
23
+
24
+
25
+ def run_quiet(args: list[str]) -> int:
26
+ """不帶參數→顯示現況;``on``→無限靜音;``off``→解除並立即 flush;``<duration>``→限時靜音。"""
27
+ args = strip_lang(args)
28
+ now = time.time()
29
+
30
+ if not args:
31
+ _print_status(now)
32
+ return 0
33
+
34
+ action = args[0]
35
+ if action == "on":
36
+ set_quiet(None)
37
+ print(_("🔇 quiet 已開啟(手動解除前一直靜音)"))
38
+ return 0
39
+
40
+ if action == "off":
41
+ clear_quiet()
42
+ flush_if_due(force=True)
43
+ print(_("🔈 quiet 已解除"))
44
+ return 0
45
+
46
+ try:
47
+ seconds = parse_duration(action)
48
+ except ValueError:
49
+ print(_("無效的 duration:{value}(例如 30m、1h)", value=action), file=sys.stderr)
50
+ return 2
51
+
52
+ set_quiet(now + seconds)
53
+ print(_("🔇 quiet 已開啟,{duration} 後自動解除", duration=action))
54
+ return 0
@@ -8,7 +8,7 @@
8
8
  legend = true
9
9
  active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
10
10
  working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置
11
- waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
11
+ waiting_window_seconds = 1800 # 零設定掃描中,近期回合結束列收斂成 IDLE 的時間窗
12
12
  codex_permission_wait_seconds = 10 # Codex 裸 PermissionRequest 後 hook 靜默超過這秒數
13
13
  # → 看板判定真的停下來等核可(🔴 等你);0 = 關閉
14
14
  detect_stop_questions = true # Stop 事件時,若最後一則 assistant 訊息「結尾」是純文字
@@ -27,6 +27,10 @@
27
27
  notify_repeat_max = 3 # 重複提醒上限;0 = 不限
28
28
  waiting_cooldown_seconds = 180 # session 離開 WAITING 又轉回時,距上次提醒未滿這段時間就
29
29
  # 不再當「新轉入」立即提醒(防翻轉轟炸);0 = 關閉(現行為)
30
+ notify_debounce_seconds = 0 # 多個 session 同時轉 🔴 等你時的通知合流窗口(秒):窗口內
31
+ # 第一批立即發(leading edge),之後的合併成一則彙總,由下個
32
+ # hook 事件 / TUI 輪詢 / quiet 解除懶惰 flush;0 = 關閉(現行為,
33
+ # 向後相容,每次轉入都各自發)
30
34
  notify_ntfy_url = "https://ntfy.sh/my-topic" # 設了才啟用 ntfy 後端(推到手機)
31
35
  notify_webhook_url = "https://example.com/hook" # 設了才啟用 webhook 後端(JSON POST)
32
36
  notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
@@ -40,12 +44,15 @@
40
44
 
41
45
  from __future__ import annotations
42
46
 
47
+ import sys
43
48
  import tomllib
44
49
  from collections.abc import Callable
45
- from dataclasses import dataclass, field
50
+ from dataclasses import dataclass, field, fields
46
51
  from functools import lru_cache
47
52
  from pathlib import Path
48
53
 
54
+ from ring.i18n import gettext as _
55
+
49
56
  CONFIG_PATH = Path.home() / ".config" / "ring" / "config.toml"
50
57
 
51
58
  # Rich 樣式字串。深淺底都看得到(避開 dim / ANSI blue)。config 的 [colors] 可逐項覆寫。
@@ -68,7 +75,7 @@ class Config:
68
75
  legend: bool = True
69
76
  active_window_seconds: int = 6 * 60 * 60
70
77
  working_threshold_seconds: int = 90
71
- waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
78
+ waiting_window_seconds: int = 1800 # 零設定掃描中,近期回合結束列收斂成 IDLE 的時間窗
72
79
  # Codex 的 hook 沒有「使用者已核可」事件也沒有心跳(0.144.4 實證):policy 自動放行時
73
80
  # 下一個事件幾秒內就到;真的停下來等人時 hook 通道完全靜默。所以「最後一個事件是
74
81
  # PermissionRequest 且已靜默超過這個門檻」就判定在等核可。0 = 關閉這個判定。
@@ -88,6 +95,7 @@ class Config:
88
95
  notify_repeat_seconds: tuple[int, ...] = (30, 120, 300)
89
96
  notify_repeat_max: int = 3 # 0 = 不限
90
97
  waiting_cooldown_seconds: int = 180 # 離開 WAITING 又轉回時的提醒冷卻期;0 = 關閉(現行為)
98
+ notify_debounce_seconds: int = 0 # 跨 session 通知合流窗口(秒);0 = 關閉(現行為,向後相容)
91
99
  notify_ntfy_url: str = "" # 完整 ntfy topic URL;空=ntfy 後端不可用
92
100
  notify_webhook_url: str = "" # webhook URL;空=webhook 後端不可用
93
101
  notify_also: tuple[str, ...] = () # 主後端之外加發的後端名(如 ["ntfy"])
@@ -97,6 +105,21 @@ class Config:
97
105
  colors: dict[str, str] = field(default_factory=lambda: dict(_DEFAULT_COLORS))
98
106
 
99
107
 
108
+ # config.toml 頂層合法鍵(含巢狀 table 名,如 "colors")——都是 Config dataclass 的欄位名。
109
+ _KNOWN_KEYS = {f.name for f in fields(Config)}
110
+
111
+
112
+ def _warn_unknown_keys(raw: dict[str, object], path: Path) -> None:
113
+ """未知的頂層鍵(多半是打錯字)→ 印一次警告到 stderr;只警告,絕不 raise、不影響載入。"""
114
+ unknown = sorted(k for k in raw if k not in _KNOWN_KEYS)
115
+ if not unknown:
116
+ return
117
+ print(
118
+ _("⚠️ {path} 有未知的設定鍵(可能打錯字,已忽略):{keys}", path=path, keys=", ".join(unknown)),
119
+ file=sys.stderr,
120
+ )
121
+
122
+
100
123
  def _as_int(v: object, default: int) -> int:
101
124
  return v if isinstance(v, int) and not isinstance(v, bool) else default
102
125
 
@@ -137,6 +160,7 @@ def load(path: Path | None = None) -> Config:
137
160
  raw = tomllib.loads(p.read_text())
138
161
  except (OSError, tomllib.TOMLDecodeError):
139
162
  return Config()
163
+ _warn_unknown_keys(raw, p)
140
164
  d = Config()
141
165
  lang = raw.get("lang")
142
166
  # 任意非空字串都接受(後端名稱由 notify 層的可插拔 registry 決定);認不得的名稱
@@ -163,6 +187,7 @@ def load(path: Path | None = None) -> Config:
163
187
  notify_repeat_seconds=_as_positive_int_tuple(raw.get("notify_repeat_seconds"), d.notify_repeat_seconds),
164
188
  notify_repeat_max=max(0, _as_int(raw.get("notify_repeat_max"), d.notify_repeat_max)),
165
189
  waiting_cooldown_seconds=max(0, _as_int(raw.get("waiting_cooldown_seconds"), d.waiting_cooldown_seconds)),
190
+ notify_debounce_seconds=max(0, _as_int(raw.get("notify_debounce_seconds"), d.notify_debounce_seconds)),
166
191
  notify_backend=notify_backend,
167
192
  notify_ntfy_url=(raw["notify_ntfy_url"] if isinstance(raw.get("notify_ntfy_url"), str) else ""),
168
193
  notify_webhook_url=(raw["notify_webhook_url"] if isinstance(raw.get("notify_webhook_url"), str) else ""),
@@ -237,6 +262,7 @@ _SETTERS: dict[str, Callable[[str], object]] = {
237
262
  "notify_repeat_seconds": _coerce_int_list,
238
263
  "notify_repeat_max": _coerce_int,
239
264
  "waiting_cooldown_seconds": _coerce_int,
265
+ "notify_debounce_seconds": _coerce_int,
240
266
  "notify_ntfy_url": str,
241
267
  "notify_webhook_url": str,
242
268
  "notify_also": _coerce_str_list,
@@ -15,6 +15,7 @@ from ring.focus.linux_wm import focuser as _linux_wm
15
15
  from ring.focus.neovim import focuser as _neovim
16
16
  from ring.focus.terminal import focuser as _terminal
17
17
  from ring.focus.tmux import focuser as _tmux
18
+ from ring.focus.trace import log_jump
18
19
  from ring.i18n import gettext as _
19
20
  from ring.registry import Session, Status
20
21
 
@@ -53,33 +54,53 @@ def focusers() -> list[Focuser]:
53
54
 
54
55
 
55
56
  def jump(session: Session) -> tuple[bool, str]:
56
- """依序問每個 focuser,誰先接手就用誰。回傳 (成功?, 訊息)。"""
57
+ """依序問每個 focuser,誰先接手就用誰。回傳 (成功?, 訊息)。
58
+
59
+ 每次跳轉(不論成敗)都記一行到 focus.jsonl,供事後歸因間歇性的跳不過去(見 ``trace``)。
60
+ """
57
61
  if session.status is Status.ENDED:
58
62
  return False, _("已離場的 session 無法跳轉")
59
63
  failures: list[str] = []
60
64
  preparations: list[str] = []
65
+ attempts: list[tuple[str, str]] = []
66
+
67
+ def _done(ok: bool, msg: str) -> tuple[bool, str]:
68
+ log_jump(
69
+ session_id=session.session_id,
70
+ provider=session.provider,
71
+ tty=session.tty or "",
72
+ tmux_target=session.tmux_target or "",
73
+ attempts=attempts,
74
+ ok=ok,
75
+ msg=msg,
76
+ )
77
+ return ok, msg
78
+
61
79
  for focuser in _FOCUSERS:
62
80
  result = focuser.try_focus(session)
63
81
  if result is None:
82
+ attempts.append((focuser.name, "skip"))
64
83
  continue
65
84
  ok, msg = result
66
85
  if ok:
86
+ attempts.append((focuser.name, "ok"))
67
87
  if getattr(focuser, "continue_after_success", False):
68
88
  preparations.append(msg)
69
89
  continue
70
90
  if preparations:
71
- return True, "; ".join([*preparations, msg])
72
- return True, msg
91
+ return _done(True, "; ".join([*preparations, msg]))
92
+ return _done(True, msg)
93
+ attempts.append((focuser.name, f"fail: {msg}"))
73
94
  failures.append(f"{focuser.name}: {msg}")
74
95
  if preparations and failures:
75
- return False, "; ".join([*preparations, *failures])
96
+ return _done(False, "; ".join([*preparations, *failures]))
76
97
  if preparations:
77
- return True, "; ".join(preparations)
98
+ return _done(True, "; ".join(preparations))
78
99
  if failures:
79
- return False, "; ".join(failures)
100
+ return _done(False, "; ".join(failures))
80
101
  if session.tty:
81
- return False, _("找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)")
82
- return False, _("沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)")
102
+ return _done(False, _("找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)"))
103
+ return _done(False, _("沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)"))
83
104
 
84
105
 
85
106
  __all__ = ["Focuser", "focusers", "jump", "register_focuser"]
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import shutil
10
10
 
11
+ from ring.i18n import gettext as _
11
12
  from ring.osascript import osascript
12
13
  from ring.registry import Session
13
14
 
@@ -16,6 +17,9 @@ class AppleScriptTTYFocuser:
16
17
  """用 session 的 tty 在某個 macOS 終端 app 裡找到對應分頁並聚焦。
17
18
 
18
19
  各 app 提供自己的 AppleScript(用 ``is running`` 守衛,沒在跑的 app 不會被喚醒)。
20
+
21
+ script 的回傳值三選一:``ok``(找到且已在前景)、``notfound``(不歸這個 app 管)、
22
+ ``unraised``(找到分頁但視窗沒浮到前景——腳本自己驗過才回這個,見 iterm2 模組)。
19
23
  """
20
24
 
21
25
  def __init__(self, app: str, script: str) -> None:
@@ -31,4 +35,8 @@ class AppleScriptTTYFocuser:
31
35
  return True, f"{self.name} {tty}"
32
36
  if rc == 0 and out == "notfound":
33
37
  return None # 這個 app 沒有這個 tty → 換下一個 focuser
38
+ if rc == 0 and out == "unraised":
39
+ # 分頁在,但視窗沒被帶到前景(多半在別的 Space 或全螢幕)。照實回失敗,
40
+ # 不要讓 TUI 顯示「跳過去了」卻什麼都沒發生。
41
+ return False, _("{app} 找到分頁了,但視窗沒浮到前景(可能在別的 Space 或全螢幕)", app=self.name)
34
42
  return False, err or out or f"returncode={rc}"
@@ -0,0 +1,82 @@
1
+ """iTerm2 focuser(macOS):用 tty 找到對應分頁並帶到前景。
2
+
3
+ 跨視窗(不是同一視窗換分頁)跳轉踩過三個坑,腳本的形狀都是為了繞開它們(皆 2026-07-25 實測):
4
+
5
+ 1. **位置型 specifier 會在迴圈中途失效**。``repeat with w in windows`` 的迴圈變數其實是
6
+ ``item M of every window`` 這種「按位置」的參照;一旦 windows 的順序在掃描途中變動
7
+ (剛跳過一次視窗、使用者手動點了別的視窗都會),下一次取值就噴
8
+ ``Invalid index (-1719)``,跳轉整個失敗——這正是「不同視窗有時抓不到」的主因。
9
+ 對策:掃描包在 ``try`` 裡、順序被動到就重掃;``select`` 之後不再用掃描時的位置,
10
+ 改以 ``current window`` 為根重找一次分頁。
11
+ 2. **``select`` 是非同步的**:送出後立刻讀狀態會讀到舊值。所以最後隔 0.1 秒回頭驗
12
+ ``current session of current window``,最多等 1 秒。驗不到就回 ``unraised``,讓上層
13
+ 照實說「沒帶到前景」(例如視窗在別的 Space),而不是假裝跳成功。
14
+ 3. **不能用的兩招**:``set frontmost of w to true``(Terminal.app 那招)在 iTerm2 噴
15
+ AppleEvent handler failed (-10000);``set index of w to 1`` 會主動重排 windows,
16
+ 等於自己製造第 1 點的 -1719。
17
+
18
+ 縮到 Dock 的視窗 ``select`` 就叫得回來(實測),``miniaturized`` 那段只是保險。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from ring.focus.applescript import AppleScriptTTYFocuser
24
+
25
+ _SCRIPT = """
26
+ if application "iTerm2" is running then
27
+ tell application "iTerm2"
28
+ repeat 3 times
29
+ try
30
+ set wIdx to 0
31
+ set tIdx to 0
32
+ set sIdx to 0
33
+ repeat with wi from 1 to (count of windows)
34
+ repeat with ti from 1 to (count of tabs of window wi)
35
+ repeat with si from 1 to (count of sessions of tab ti of window wi)
36
+ if tty of session si of tab ti of window wi is "{tty}" then
37
+ set wIdx to wi
38
+ set tIdx to ti
39
+ set sIdx to si
40
+ exit repeat
41
+ end if
42
+ end repeat
43
+ if wIdx is not 0 then exit repeat
44
+ end repeat
45
+ if wIdx is not 0 then exit repeat
46
+ end repeat
47
+ if wIdx is 0 then return "notfound"
48
+ activate
49
+ try
50
+ if miniaturized of window wIdx then set miniaturized of window wIdx to false
51
+ end try
52
+ select window wIdx
53
+ tell current window
54
+ repeat with ti2 from 1 to (count of tabs)
55
+ repeat with si2 from 1 to (count of sessions of tab ti2)
56
+ if tty of session si2 of tab ti2 is "{tty}" then
57
+ select tab ti2
58
+ select session si2 of tab ti2
59
+ exit repeat
60
+ end if
61
+ end repeat
62
+ end repeat
63
+ end tell
64
+ repeat 10 times
65
+ delay 0.1
66
+ try
67
+ if tty of current session of current window is "{tty}" then return "ok"
68
+ end try
69
+ end repeat
70
+ return "unraised"
71
+ on error number errNum
72
+ if errNum is -128 then error number -128
73
+ delay 0.2
74
+ end try
75
+ end repeat
76
+ return "unraised"
77
+ end tell
78
+ end if
79
+ return "notfound"
80
+ """
81
+
82
+ focuser = AppleScriptTTYFocuser("iTerm2", _SCRIPT)