ring-cli 0.6.0__tar.gz → 0.8.0__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 (56) hide show
  1. {ring_cli-0.6.0 → ring_cli-0.8.0}/PKG-INFO +18 -6
  2. {ring_cli-0.6.0 → ring_cli-0.8.0}/README.en.md +17 -5
  3. {ring_cli-0.6.0 → ring_cli-0.8.0}/pyproject.toml +1 -1
  4. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/cli.py +69 -12
  5. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/commands/completion.py +13 -1
  6. ring_cli-0.8.0/src/ring/commands/digest.py +142 -0
  7. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/commands/doctor.py +17 -2
  8. ring_cli-0.8.0/src/ring/commands/focus.py +53 -0
  9. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/commands/gc.py +15 -0
  10. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/config.py +5 -1
  11. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/focus/__init__.py +16 -4
  12. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/focus/base.py +4 -1
  13. ring_cli-0.8.0/src/ring/focus/neovim.py +184 -0
  14. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/gc.py +48 -5
  15. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/hook.py +23 -2
  16. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/hook_protocol.py +44 -0
  17. ring_cli-0.8.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  18. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/locale/en/LC_MESSAGES/ring.po +123 -0
  19. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/locale/ring.pot +281 -111
  20. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/__init__.py +17 -4
  21. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/base.py +12 -1
  22. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/registry.py +352 -12
  23. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/sources/__init__.py +40 -2
  24. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/tui.py +79 -5
  25. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/watcher.py +30 -0
  26. ring_cli-0.6.0/src/ring/commands/focus.py +0 -23
  27. ring_cli-0.6.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  28. {ring_cli-0.6.0 → ring_cli-0.8.0}/LICENSE +0 -0
  29. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/__init__.py +0 -0
  30. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/__main__.py +0 -0
  31. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/commands/__init__.py +0 -0
  32. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/commands/_args.py +0 -0
  33. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/commands/hook.py +0 -0
  34. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/commands/stats.py +0 -0
  35. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/focus/applescript.py +0 -0
  36. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/focus/iterm2.py +0 -0
  37. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/focus/linux_wm.py +0 -0
  38. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/focus/terminal.py +0 -0
  39. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/focus/tmux.py +0 -0
  40. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/i18n.py +0 -0
  41. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/ipc.py +0 -0
  42. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/labels.py +0 -0
  43. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/command.py +0 -0
  44. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/notify_send.py +0 -0
  45. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/ntfy.py +0 -0
  46. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/osascript_notifier.py +0 -0
  47. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/terminal_notifier.py +0 -0
  48. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/notify/webhook.py +0 -0
  49. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/osascript.py +0 -0
  50. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/plugins.py +0 -0
  51. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/sources/base.py +0 -0
  52. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/sources/claude_code.py +0 -0
  53. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/sources/codex.py +0 -0
  54. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/sources/hook_registry.py +0 -0
  55. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/stats.py +0 -0
  56. {ring_cli-0.6.0 → ring_cli-0.8.0}/src/ring/transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ring-cli
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
5
5
  Keywords: claude-code,codex,tui,dashboard,session,monitor,rich
6
6
  Author: Wei Lee
@@ -116,10 +116,11 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
116
116
  | `ring --all` | Show ended sessions too |
117
117
  | `ring --no-legend` | Hide the legend |
118
118
  | `ring --lang zh-Hant` | Switch UI language |
119
- | `ring focus SESSION_ID` | Focus a specific session |
119
+ | `ring focus SESSION_ID` | Focus a specific session; unique prefixes work |
120
120
  | `ring config` | Show config path and effective settings |
121
121
  | `ring config set KEY VALUE` | Write one config value |
122
122
  | `ring doctor` | Read-only environment diagnosis |
123
+ | `ring digest --since 4h` | Away summary: recent session state and wait stats |
123
124
  | `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
124
125
  | `ring gc` | Clean RiNG-owned stale state files |
125
126
  | `ring --format json` | Machine-readable board snapshot (for jq / scripts) |
@@ -147,21 +148,24 @@ eval "$(ring completion zsh)"
147
148
  eval "$(ring completion bash)"
148
149
  ```
149
150
 
150
- Completes subcommands, flags, and `config set` keys.
151
+ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a session id or unique prefix.
151
152
 
152
153
  ## Watch Mode
153
154
 
154
155
  - With **Textual** (`[tui]` extra) in a real terminal: interactive TUI.
155
- Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `r` to refresh, and `q` to quit.
156
+ Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
156
157
  If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
157
158
  When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
158
159
  - Otherwise: Rich polling; without Rich, plain text.
159
160
 
160
161
  ### Jump To A Session
161
162
 
162
- Select a session and press `Enter`. RiNG focuses the terminal where that session is running.
163
+ Select a session and press `Enter`, or run `ring focus SESSION_ID` with a full id or unique prefix.
164
+ RiNG focuses the terminal where that session is running. If the TUI is already open, `ring focus`
165
+ hands the request to the TUI so it selects that session; otherwise it focuses the terminal directly.
163
166
 
164
167
  - **tmux**: switches directly to the pane via `switch-client`.
168
+ - **Neovim `:terminal`**: uses the terminal job's inherited `$NVIM` server socket to switch to the exact buffer, then lets the outer tmux or terminal focuser raise its pane/window.
165
169
  - **iTerm2 / Terminal.app** on macOS: uses the session `tty` and AppleScript to focus the matching tab. The first run may ask for macOS Automation permission.
166
170
  - **Linux X11 window** (`wmctrl`, best-effort fallback): for Linux without tmux — walks from the `tty` up to the terminal window that owns it and raises it via `wmctrl`. **Limits**: X11 only (usually a no-op on Wayland), raises the whole window but cannot pick the tab, and gnome-terminal's client/server model may not match. Requires `apt install wmctrl`.
167
171
 
@@ -175,6 +179,13 @@ and sends a system notification right then — **no RiNG board needs to be open*
175
179
  after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
176
180
  also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
177
181
 
182
+ A second kind of reminder is 🟡 **idle for too long** (`idle_threshold_seconds`, default 2h): once a
183
+ session has sat idle past the threshold, it notifies once. Unlike waiting, idle is time-based — there
184
+ is no event to hang a hook on, so it can only be detected by polling. That means it **fires only while
185
+ `ring watch` or a TUI is open** (and when both are open, only one of them fires it, never twice). Close
186
+ every board and idle reminders go silent — waiting still rings you, since that path runs off the hook.
187
+ This is a deliberate trade-off: idle is a secondary signal, not worth a resident background process.
188
+
178
189
  For clickable notifications on macOS, install `terminal-notifier`:
179
190
 
180
191
  ```sh
@@ -362,6 +373,7 @@ legend = true
362
373
  active_window_seconds = 21600
363
374
  working_threshold_seconds = 90
364
375
  waiting_window_seconds = 1800
376
+ idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
365
377
  notify_sound = true
366
378
  notify_sound_name = "Glass"
367
379
  notify_ignore_dnd = false
@@ -371,7 +383,7 @@ notify_repeat_max = 3
371
383
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
372
384
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
373
385
  notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
374
- focusers = ["tmux", "iTerm2", "Terminal", "linux-wm"]
386
+ focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"]
375
387
  plugins = [] # external plugin modules imported at startup (see Extending)
376
388
 
377
389
  [colors]
@@ -91,10 +91,11 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
91
91
  | `ring --all` | Show ended sessions too |
92
92
  | `ring --no-legend` | Hide the legend |
93
93
  | `ring --lang zh-Hant` | Switch UI language |
94
- | `ring focus SESSION_ID` | Focus a specific session |
94
+ | `ring focus SESSION_ID` | Focus a specific session; unique prefixes work |
95
95
  | `ring config` | Show config path and effective settings |
96
96
  | `ring config set KEY VALUE` | Write one config value |
97
97
  | `ring doctor` | Read-only environment diagnosis |
98
+ | `ring digest --since 4h` | Away summary: recent session state and wait stats |
98
99
  | `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
99
100
  | `ring gc` | Clean RiNG-owned stale state files |
100
101
  | `ring --format json` | Machine-readable board snapshot (for jq / scripts) |
@@ -122,21 +123,24 @@ eval "$(ring completion zsh)"
122
123
  eval "$(ring completion bash)"
123
124
  ```
124
125
 
125
- Completes subcommands, flags, and `config set` keys.
126
+ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a session id or unique prefix.
126
127
 
127
128
  ## Watch Mode
128
129
 
129
130
  - With **Textual** (`[tui]` extra) in a real terminal: interactive TUI.
130
- Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `r` to refresh, and `q` to quit.
131
+ Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
131
132
  If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
132
133
  When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
133
134
  - Otherwise: Rich polling; without Rich, plain text.
134
135
 
135
136
  ### Jump To A Session
136
137
 
137
- Select a session and press `Enter`. RiNG focuses the terminal where that session is running.
138
+ Select a session and press `Enter`, or run `ring focus SESSION_ID` with a full id or unique prefix.
139
+ RiNG focuses the terminal where that session is running. If the TUI is already open, `ring focus`
140
+ hands the request to the TUI so it selects that session; otherwise it focuses the terminal directly.
138
141
 
139
142
  - **tmux**: switches directly to the pane via `switch-client`.
143
+ - **Neovim `:terminal`**: uses the terminal job's inherited `$NVIM` server socket to switch to the exact buffer, then lets the outer tmux or terminal focuser raise its pane/window.
140
144
  - **iTerm2 / Terminal.app** on macOS: uses the session `tty` and AppleScript to focus the matching tab. The first run may ask for macOS Automation permission.
141
145
  - **Linux X11 window** (`wmctrl`, best-effort fallback): for Linux without tmux — walks from the `tty` up to the terminal window that owns it and raises it via `wmctrl`. **Limits**: X11 only (usually a no-op on Wayland), raises the whole window but cannot pick the tab, and gnome-terminal's client/server model may not match. Requires `apt install wmctrl`.
142
146
 
@@ -150,6 +154,13 @@ and sends a system notification right then — **no RiNG board needs to be open*
150
154
  after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
151
155
  also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
152
156
 
157
+ A second kind of reminder is 🟡 **idle for too long** (`idle_threshold_seconds`, default 2h): once a
158
+ session has sat idle past the threshold, it notifies once. Unlike waiting, idle is time-based — there
159
+ is no event to hang a hook on, so it can only be detected by polling. That means it **fires only while
160
+ `ring watch` or a TUI is open** (and when both are open, only one of them fires it, never twice). Close
161
+ every board and idle reminders go silent — waiting still rings you, since that path runs off the hook.
162
+ This is a deliberate trade-off: idle is a secondary signal, not worth a resident background process.
163
+
153
164
  For clickable notifications on macOS, install `terminal-notifier`:
154
165
 
155
166
  ```sh
@@ -337,6 +348,7 @@ legend = true
337
348
  active_window_seconds = 21600
338
349
  working_threshold_seconds = 90
339
350
  waiting_window_seconds = 1800
351
+ idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
340
352
  notify_sound = true
341
353
  notify_sound_name = "Glass"
342
354
  notify_ignore_dnd = false
@@ -346,7 +358,7 @@ notify_repeat_max = 3
346
358
  notify_ntfy_url = "" # full ntfy topic URL enables phone push (e.g. https://ntfy.sh/my-topic)
347
359
  notify_webhook_url = "" # URL enables the generic webhook backend (JSON POST)
348
360
  notify_also = [] # extra backends fired besides the primary, e.g. ["ntfy"]
349
- focusers = ["tmux", "iTerm2", "Terminal", "linux-wm"]
361
+ focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"]
350
362
  plugins = [] # external plugin modules imported at startup (see Extending)
351
363
 
352
364
  [colors]
@@ -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.6.0"
5
+ version = "0.8.0"
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"
@@ -20,6 +20,7 @@ from typing import Any
20
20
  from ring import __version__
21
21
  from ring.commands._args import strip_lang as _strip_lang
22
22
  from ring.commands.completion import run_completion
23
+ from ring.commands.digest import run_digest
23
24
  from ring.commands.doctor import run_doctor
24
25
  from ring.commands.focus import run_focus
25
26
  from ring.commands.gc import run_gc
@@ -32,10 +33,12 @@ from ring.config import get_config as get_config
32
33
  from ring.config import set_value as set_value
33
34
  from ring.i18n import gettext as _
34
35
  from ring.i18n import ngettext, set_lang
36
+ from ring.ipc import read_tui_presence
35
37
  from ring.labels import load_labels
36
38
  from ring.plugins import load_plugins
37
39
  from ring.registry import Session, Status, running_agent_pids
38
40
  from ring.sources import discover_sessions
41
+ from ring.watcher import IdleAlertScheduler
39
42
 
40
43
  try:
41
44
  from rich.box import SIMPLE_HEAD
@@ -165,6 +168,11 @@ def _rich_legend() -> Text:
165
168
  return Text.assemble(*[(p.plain, p.style) for p in parts])
166
169
 
167
170
 
171
+ def _status_text(s: Session) -> str:
172
+ suffix = f" {s.waiting_icon}" if s.status is Status.WAITING and s.waiting_icon else ""
173
+ return f"{s.status.marker} {status_label(s.status)}{suffix}"
174
+
175
+
168
176
  def _rich_renderable(sessions: list[Session], show_legend: bool, show_tool: bool = True) -> Group:
169
177
  pids = running_agent_pids()
170
178
  blocks: list[Any] = [Text(_header(len(sessions), len(pids)), style="bold")]
@@ -187,7 +195,7 @@ def _rich_renderable(sessions: list[Session], show_legend: bool, show_tool: bool
187
195
 
188
196
  labels = load_labels()
189
197
  for s in sessions:
190
- status_cell = Text(f"{s.status.marker} {status_label(s.status)}", style=_STATUS_STYLE[s.status])
198
+ status_cell = Text(_status_text(s), style=_STATUS_STYLE[s.status])
191
199
  progress = f"{s.todo[0]}/{s.todo[1]}" if s.todo else "·"
192
200
  loc_cell = f"📍{_middle_truncate(s.location, _LOC_MAX)}"
193
201
  project_cell = labeled_project(s.project, labels.get(s.session_id, ""))
@@ -216,6 +224,7 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
216
224
  rows = [
217
225
  (
218
226
  s.status.marker,
227
+ s.waiting_icon if s.status is Status.WAITING else "",
219
228
  provider_label(s.provider),
220
229
  labeled_project(s.project, labels.get(s.session_id, "")),
221
230
  f"{s.todo[0]}/{s.todo[1]}" if s.todo else "·",
@@ -226,18 +235,19 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
226
235
  for s in sessions
227
236
  ]
228
237
  c_tool, c_proj, c_prog, c_idle, c_loc, c_act = _("工具"), _("專案"), _("進度"), _("閒置"), _("去哪"), _("動作")
229
- w_tool = max(len(c_tool), *(len(r[1]) for r in rows))
230
- w_proj = max(len(c_proj), *(len(r[2]) for r in rows))
231
- w_prog = max(len(c_prog), *(len(r[3]) for r in rows))
232
- w_ago = max(len(c_idle), 3, *(len(r[4]) for r in rows))
233
- w_loc = max(len(c_loc), *(len(r[5]) for r in rows))
238
+ w_tool = max(len(c_tool), *(len(r[2]) for r in rows))
239
+ w_proj = max(len(c_proj), *(len(r[3]) for r in rows))
240
+ w_prog = max(len(c_prog), *(len(r[4]) for r in rows))
241
+ w_ago = max(len(c_idle), 3, *(len(r[5]) for r in rows))
242
+ w_loc = max(len(c_loc), *(len(r[6]) for r in rows))
234
243
  tool_h = f"{c_tool:<{w_tool}} " if show_tool else ""
235
244
  header = f" {tool_h}{c_proj:<{w_proj}} {c_prog:>{w_prog}} {c_idle:>{w_ago}} {c_loc:<{w_loc}} {c_act}"
236
245
  lines += ["", header]
237
- for marker, tool, project, prog, ago, loc, action in rows:
246
+ for marker, kind_icon, tool, project, prog, ago, loc, action in rows:
238
247
  tool_c = f"{tool:<{w_tool}} " if show_tool else ""
248
+ marker_c = f"{marker}{kind_icon}"
239
249
  lines.append(
240
- f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} {ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
250
+ f" {marker_c:<2} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} {ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
241
251
  )
242
252
  return "\n".join(lines)
243
253
 
@@ -261,6 +271,10 @@ def render_json(sessions: list[Session]) -> str:
261
271
  "label": labels.get(s.session_id, ""),
262
272
  "status": s.status.value,
263
273
  "marker": s.status.marker,
274
+ "waiting_kind": s.waiting_kind,
275
+ "waiting_icon": s.waiting_icon,
276
+ "hook_stale": s.hook_stale,
277
+ "heartbeat_at": s.heartbeat_at,
264
278
  "cwd": s.cwd,
265
279
  "location": s.location,
266
280
  "tmux_target": s.tmux_target,
@@ -388,15 +402,33 @@ def run_config(args: list[str]) -> int:
388
402
 
389
403
 
390
404
  def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
391
- # 系統通知由 ``ring hook`` 在 session 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
392
- # watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
405
+ # 🔴 等你的通知由 ``ring hook`` 在 session 轉態的當下就地發出(見 hook._ring_waiting_now),
406
+ # 這樣關掉看板也照樣 ring 你;watch 迴圈額外負責輪詢發 🟡 閒置提醒——
407
+ # 閒置是時間累積出來的,沒有事件可掛,只能靠輪詢偵測。
393
408
  frames = 0
394
409
  footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
410
+ idle_alerts = IdleAlertScheduler(get_config().idle_threshold_seconds)
411
+
412
+ def _notify_idle_alerts(sessions: list[Session]) -> None:
413
+ # scheduler 照常 feed 讓狀態持續前進;但若 TUI(ring tui)也開著,
414
+ # 就讓它去發、watch 這邊不重複發系統通知——否則同一 session 會被兩個行程各響一次。
415
+ # 已標記為 due 的 session 在這裡仍算「提醒過」,所以 TUI 關掉後 watch 不會補發。
416
+ alerts = idle_alerts.feed(sessions)
417
+ if not alerts or read_tui_presence() is not None:
418
+ return
419
+ try:
420
+ from ring.notify import notify_idle
421
+
422
+ notify_idle(alerts)
423
+ except Exception:
424
+ pass
425
+
395
426
  if not HAVE_RICH:
396
427
  try:
397
428
  while True:
398
429
  sys.stdout.write("\033[2J\033[H")
399
430
  sessions = board(show_all)
431
+ _notify_idle_alerts(sessions)
400
432
  print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
401
433
  print(f"\n{footer_text}")
402
434
  sys.stdout.flush()
@@ -412,6 +444,7 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
412
444
  with Live(console=console, screen=True, auto_refresh=False) as live:
413
445
  while True:
414
446
  sessions = board(show_all)
447
+ _notify_idle_alerts(sessions)
415
448
  body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
416
449
  live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
417
450
  frames += 1
@@ -446,6 +479,7 @@ commands:
446
479
  focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
447
480
  gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔
448
481
  doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示
482
+ digest [--since 4h] 離席摘要:彙整最近 session 狀態
449
483
  stats [--since 7d] 等待統計:你讓 agent 🔴 等了多久(hook 模式)
450
484
  completion SHELL 印出 shell 補全腳本(zsh / bash)
451
485
  """
@@ -502,6 +536,16 @@ options:
502
536
 
503
537
  唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性、維護提示與設定檔位置。
504
538
  不寫任何檔案、不安裝、不發通知;固定回傳 0。
539
+ """
540
+ ),
541
+ "digest": _(
542
+ """usage: ring digest [--since DURATION] [--format text|json]
543
+
544
+ 離席摘要:彙整最近一段時間的 session 狀態與等待統計。
545
+
546
+ options:
547
+ --since DURATION 摘要時間窗(例如 30m、4h、1d;預設 4h)
548
+ --format text|json 輸出格式
505
549
  """
506
550
  ),
507
551
  "stats": _(
@@ -533,7 +577,18 @@ def main(argv: list[str] | None = None) -> int:
533
577
  if (
534
578
  raw
535
579
  and raw[0]
536
- in {"hook", "install-hooks", "remove-hooks", "config", "focus", "gc", "doctor", "stats", "completion"}
580
+ in {
581
+ "hook",
582
+ "install-hooks",
583
+ "remove-hooks",
584
+ "config",
585
+ "focus",
586
+ "gc",
587
+ "doctor",
588
+ "digest",
589
+ "stats",
590
+ "completion",
591
+ }
537
592
  and any(arg in {"-h", "--help"} for arg in raw[1:])
538
593
  ):
539
594
  print(_subcommand_help(raw[0]), end="")
@@ -551,11 +606,13 @@ def main(argv: list[str] | None = None) -> int:
551
606
  return run_gc(raw[1:])
552
607
  if raw and raw[0] == "doctor":
553
608
  return run_doctor(raw[1:])
609
+ if raw and raw[0] == "digest":
610
+ return run_digest(raw[1:])
554
611
  if raw and raw[0] == "stats":
555
612
  return run_stats(raw[1:])
556
613
  if raw and raw[0] == "completion":
557
614
  return run_completion(raw[1:])
558
- if raw and raw[0] == "focus" and len(raw) >= 2:
615
+ if raw and raw[0] == "focus":
559
616
  return run_focus(raw[1:])
560
617
 
561
618
  parser = argparse.ArgumentParser(
@@ -28,6 +28,7 @@ _COMMANDS = (
28
28
  "focus",
29
29
  "gc",
30
30
  "doctor",
31
+ "digest",
31
32
  "stats",
32
33
  "completion",
33
34
  )
@@ -56,6 +57,7 @@ _ring() {{
56
57
  'focus:focus a session by id'
57
58
  'gc:clean stale RiNG state files'
58
59
  'doctor:read-only environment diagnosis'
60
+ 'digest:away summary'
59
61
  'stats:waiting-time statistics'
60
62
  'completion:print shell completion script'
61
63
  )
@@ -76,6 +78,9 @@ _ring() {{
76
78
  fi
77
79
 
78
80
  case $words[2] in
81
+ focus)
82
+ _message 'session id or unique prefix'
83
+ ;;
79
84
  hook)
80
85
  _arguments '--provider[provider name]:provider:(claude-code codex)'
81
86
  ;;
@@ -88,6 +93,11 @@ _ring() {{
88
93
  '--older-than[age threshold, e.g. 30m / 2h / 7d]:duration:' \\
89
94
  '--all-ended[remove every ended registry]'
90
95
  ;;
96
+ digest)
97
+ _arguments \\
98
+ '--since[summary window, e.g. 30m / 4h / 1d]:duration:' \\
99
+ '--format[output format]:format:(text json)'
100
+ ;;
91
101
  completion)
92
102
  (( CURRENT == 3 )) && _values 'shell' zsh bash
93
103
  ;;
@@ -120,15 +130,17 @@ _ring_completion() {{
120
130
  fi
121
131
 
122
132
  case "$prev" in
123
- --format) COMPREPLY=( $(compgen -W "table json oneline" -- "$cur") ); return ;;
133
+ --format) COMPREPLY=( $(compgen -W "table json oneline text" -- "$cur") ); return ;;
124
134
  --lang) COMPREPLY=( $(compgen -W "en zh-Hant" -- "$cur") ); return ;;
125
135
  --provider) COMPREPLY=( $(compgen -W "claude-code codex" -- "$cur") ); return ;;
126
136
  esac
127
137
 
128
138
  case "${{COMP_WORDS[1]}}" in
139
+ focus) COMPREPLY=() ;;
129
140
  hook) COMPREPLY=( $(compgen -W "--provider" -- "$cur") ) ;;
130
141
  install-hooks|remove-hooks) COMPREPLY=( $(compgen -W "--dry-run" -- "$cur") ) ;;
131
142
  gc) COMPREPLY=( $(compgen -W "--dry-run --older-than --all-ended" -- "$cur") ) ;;
143
+ digest) COMPREPLY=( $(compgen -W "--since --format" -- "$cur") ) ;;
132
144
  completion) [[ $COMP_CWORD -eq 2 ]] && COMPREPLY=( $(compgen -W "zsh bash" -- "$cur") ) ;;
133
145
  config)
134
146
  if [[ $COMP_CWORD -eq 2 ]]; then
@@ -0,0 +1,142 @@
1
+ """``ring digest`` command handler——回座時快速掃過目前狀態。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Literal
11
+
12
+ from ring.commands._args import strip_lang
13
+ from ring.gc import parse_duration
14
+ from ring.i18n import gettext as _
15
+ from ring.registry import Session, Status
16
+ from ring.sources import discover_sessions
17
+ from ring.stats import collect_waits
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Digest:
22
+ since: str
23
+ since_seconds: float
24
+ generated_at: float
25
+ waiting: list[Session]
26
+ idle: list[Session]
27
+ ended: list[Session]
28
+ waits: int
29
+ wait_seconds: float
30
+ ongoing_waits: int
31
+
32
+
33
+ def _rel(seconds: float) -> str:
34
+ s = int(max(0, seconds))
35
+ if s < 60:
36
+ return f"{s}s"
37
+ if s < 3600:
38
+ return f"{s // 60}m"
39
+ return f"{s // 3600}h{(s % 3600) // 60:02d}m"
40
+
41
+
42
+ def _session_line(s: Session) -> str:
43
+ detail = f" — {s.waiting_detail}" if s.waiting_detail else ""
44
+ kind = f"{s.waiting_icon} " if s.waiting_icon else ""
45
+ return f"{kind}{s.project} ({_rel(s.idle_for)}){detail}"
46
+
47
+
48
+ def build_digest(*, since: str, since_seconds: float, now: float | None = None) -> Digest:
49
+ generated_at = time.time() if now is None else now
50
+ cutoff = generated_at - since_seconds
51
+ sessions = [s for s in discover_sessions() if s.last_active >= cutoff or s.status is Status.WAITING]
52
+ waits = collect_waits(since_seconds, now=generated_at)
53
+ return Digest(
54
+ since=since,
55
+ since_seconds=since_seconds,
56
+ generated_at=generated_at,
57
+ waiting=sorted((s for s in sessions if s.status is Status.WAITING), key=lambda s: s.last_active),
58
+ idle=sorted((s for s in sessions if s.status is Status.IDLE), key=lambda s: s.last_active, reverse=True),
59
+ ended=sorted((s for s in sessions if s.status is Status.ENDED), key=lambda s: s.last_active, reverse=True),
60
+ waits=len(waits),
61
+ wait_seconds=sum(w.seconds for w in waits),
62
+ ongoing_waits=sum(1 for w in waits if w.ongoing),
63
+ )
64
+
65
+
66
+ def render_text(digest: Digest) -> str:
67
+ lines = [_("🎤 RiNG digest — 最近 {since}", since=digest.since)]
68
+ if not (digest.waiting or digest.idle or digest.ended or digest.waits):
69
+ lines.append(_(" (這段時間沒有 session 活動。)"))
70
+ return "\n".join(lines)
71
+
72
+ if digest.waiting:
73
+ lines.append(_(" 🔴 正在等你:{n}", n=len(digest.waiting)))
74
+ lines.extend(f" - {_session_line(s)}" for s in digest.waiting[:5])
75
+ if digest.idle:
76
+ lines.append(_(" 🟡 已停著:{n}", n=len(digest.idle)))
77
+ lines.extend(f" - {_session_line(s)}" for s in digest.idle[:5])
78
+ if digest.ended:
79
+ lines.append(_(" ⚫ 已離場:{n}", n=len(digest.ended)))
80
+ lines.extend(f" - {_session_line(s)}" for s in digest.ended[:5])
81
+ if digest.waits:
82
+ lines.append(_(" 等待統計:{n} 次,共 {duration}", n=digest.waits, duration=_rel(digest.wait_seconds)))
83
+ if digest.ongoing_waits:
84
+ lines.append(_(" 其中 {n} 段還在等你。", n=digest.ongoing_waits))
85
+ return "\n".join(lines)
86
+
87
+
88
+ def render_json(digest: Digest) -> str:
89
+ def _pack(s: Session) -> dict[str, object]:
90
+ return {
91
+ "session_id": s.session_id,
92
+ "project": s.project,
93
+ "status": s.status.value,
94
+ "last_active": s.last_active,
95
+ "idle_seconds": round(s.idle_for, 1),
96
+ "waiting_kind": s.waiting_kind,
97
+ "waiting_detail": s.waiting_detail,
98
+ "source": s.source,
99
+ }
100
+
101
+ return json.dumps(
102
+ {
103
+ "generated_at": digest.generated_at,
104
+ "since": digest.since,
105
+ "since_seconds": digest.since_seconds,
106
+ "waiting": [_pack(s) for s in digest.waiting],
107
+ "idle": [_pack(s) for s in digest.idle],
108
+ "ended": [_pack(s) for s in digest.ended],
109
+ "waits": {
110
+ "count": digest.waits,
111
+ "total_seconds": round(digest.wait_seconds, 1),
112
+ "ongoing": digest.ongoing_waits,
113
+ },
114
+ },
115
+ ensure_ascii=False,
116
+ indent=2,
117
+ )
118
+
119
+
120
+ def run_digest(args: list[str]) -> int:
121
+ args = strip_lang(args)
122
+ parser = argparse.ArgumentParser(prog="ring digest", description=_("離席摘要:彙整最近一段時間的 session 狀態。"))
123
+ parser.add_argument(
124
+ "--since",
125
+ default="4h",
126
+ metavar="DURATION",
127
+ help=_("摘要時間窗(例如 30m、4h、1d;預設 4h)"),
128
+ )
129
+ parser.add_argument("--format", choices=["text", "json"], default="text", help=_("輸出格式:text / json"))
130
+ try:
131
+ ns = parser.parse_args(args)
132
+ since_seconds = parse_duration(ns.since)
133
+ except SystemExit as e:
134
+ return e.code if isinstance(e.code, int) else 2
135
+ except ValueError:
136
+ print(_("無效的 --since:{value}", value=ns.since), file=sys.stderr)
137
+ return 2
138
+
139
+ digest = build_digest(since=ns.since, since_seconds=since_seconds)
140
+ fmt: Literal["text", "json"] = ns.format
141
+ print(render_json(digest) if fmt == "json" else render_text(digest))
142
+ return 0
@@ -10,7 +10,7 @@ from ring.config import CONFIG_PATH, get_config
10
10
  from ring.gc import DEFAULT_OLDER_THAN_SECONDS
11
11
  from ring.gc import collect_candidates as gc_collect_candidates
12
12
  from ring.i18n import gettext as _
13
- from ring.sources import sources
13
+ from ring.sources import discover_sessions, sources
14
14
 
15
15
 
16
16
  def run_doctor(args: list[str]) -> int:
@@ -60,6 +60,18 @@ def run_doctor(args: list[str]) -> int:
60
60
  print(f" {label:<{width_hook}} {msg}")
61
61
  print()
62
62
 
63
+ print(_("Hook 心跳偵測"))
64
+ try:
65
+ stale_hooks = [s for s in discover_sessions() if s.hook_stale]
66
+ if stale_hooks:
67
+ sample = ", ".join(s.project for s in stale_hooks[:3])
68
+ print(f" {_('狀態')}:{_('可能失效')}({_('{n} 個 session', n=len(stale_hooks))}:{sample})")
69
+ else:
70
+ print(f" {_('狀態')}:{_('正常')}")
71
+ except Exception:
72
+ print(f" {_('狀態')}:{_('偵測失敗')}")
73
+ print()
74
+
63
75
  print(_("通知後端"))
64
76
  print(f" {_('目前設定')}:{cfg.notify_backend}")
65
77
  notifier_list = notifiers()
@@ -87,7 +99,10 @@ def run_doctor(args: list[str]) -> int:
87
99
  width_f = max(len(f.name) for f in focuser_list) if focuser_list else 10
88
100
  for f in focuser_list:
89
101
  name_lower = f.name.lower()
90
- if name_lower == "tmux":
102
+ if name_lower == "neovim":
103
+ avail = shutil.which("nvim") is not None
104
+ avail_str = _("可用") if avail else _("不可用(nvim 不在 PATH)")
105
+ elif name_lower == "tmux":
91
106
  avail = shutil.which("tmux") is not None
92
107
  avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
93
108
  elif shutil.which("osascript") is None:
@@ -0,0 +1,53 @@
1
+ """``ring focus`` command handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ring.i18n import gettext as _
8
+ from ring.registry import Session
9
+
10
+
11
+ def _resolve_session(query: str) -> tuple[Session | None, str | None, int]:
12
+ from ring.sources import discover_sessions, get_by_id
13
+
14
+ exact = get_by_id(query)
15
+ if exact is not None:
16
+ return exact, None, 0
17
+
18
+ matches = [s for s in discover_sessions() if s.session_id.startswith(query)]
19
+ if len(matches) == 1:
20
+ return matches[0], None, 0
21
+ if len(matches) > 1:
22
+ sample = ", ".join(s.session_id for s in matches[:5])
23
+ more = _(" 等 {n} 筆", n=len(matches)) if len(matches) > 5 else ""
24
+ return (
25
+ None,
26
+ _("session id 前綴不唯一:{query}(符合:{matches}{more})", query=query, matches=sample, more=more),
27
+ 2,
28
+ )
29
+ return None, _("找不到 session:{query}", query=query), 1
30
+
31
+
32
+ def run_focus(args: list[str]) -> int:
33
+ if len(args) < 1:
34
+ print(_("用法:ring focus SESSION_ID"), file=sys.stderr)
35
+ return 2
36
+
37
+ from ring.focus import jump as focus_jump
38
+ from ring.ipc import read_tui_presence, write_focus_request
39
+
40
+ query = args[0]
41
+ session, error, error_code = _resolve_session(query)
42
+ if session is None:
43
+ print(error or _("找不到 session:{query}", query=query), file=sys.stderr)
44
+ return error_code
45
+ presence = read_tui_presence()
46
+ if presence is not None:
47
+ write_focus_request(session.session_id)
48
+ else:
49
+ ok, message = focus_jump(session)
50
+ if not ok:
51
+ print(message, file=sys.stderr)
52
+ return 1
53
+ return 0
@@ -43,4 +43,19 @@ def run_gc(args: list[str]) -> int:
43
43
  print(f"{prefix}{candidate.path} ({candidate.reason})")
44
44
  for candidate, error in result.errors:
45
45
  print(f" ! {candidate.path} ({error})", file=sys.stderr)
46
+
47
+ if result.hidden_stale:
48
+ hidden_action = _("將清掉") if result.dry_run else _("已清掉")
49
+ print(
50
+ _(
51
+ "隱藏清單:{action} {count} 筆過期/找不到的條目",
52
+ action=hidden_action,
53
+ count=len(result.hidden_stale),
54
+ )
55
+ )
56
+ for sid in result.hidden_stale:
57
+ prefix = " - " if result.dry_run else " ✓ "
58
+ print(f"{prefix}{sid}")
59
+ print(_("目前隱藏中:{count} 個 session", count=result.hidden_remaining))
60
+
46
61
  return 1 if result.errors else 0