ring-cli 0.7.0__tar.gz → 0.8.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.
- {ring_cli-0.7.0 → ring_cli-0.8.1}/PKG-INFO +16 -5
- {ring_cli-0.7.0 → ring_cli-0.8.1}/README.en.md +15 -4
- {ring_cli-0.7.0 → ring_cli-0.8.1}/pyproject.toml +1 -1
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/__init__.py +3 -1
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/cli.py +69 -12
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/commands/completion.py +13 -1
- ring_cli-0.8.1/src/ring/commands/digest.py +142 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/commands/doctor.py +13 -1
- ring_cli-0.8.1/src/ring/commands/focus.py +53 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/commands/gc.py +15 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/config.py +4 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/gc.py +48 -5
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/hook.py +23 -2
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/hook_protocol.py +44 -0
- ring_cli-0.8.1/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/locale/en/LC_MESSAGES/ring.po +120 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/locale/ring.pot +275 -109
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/__init__.py +17 -4
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/base.py +12 -1
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/registry.py +352 -12
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/sources/__init__.py +40 -2
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/tui.py +79 -5
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/watcher.py +30 -0
- ring_cli-0.7.0/src/ring/commands/focus.py +0 -23
- ring_cli-0.7.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/LICENSE +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/__main__.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/base.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/i18n.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/ipc.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/labels.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/command.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/osascript.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/plugins.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/sources/base.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/stats.py +0 -0
- {ring_cli-0.7.0 → ring_cli-0.8.1}/src/ring/transcript.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ring-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
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,19 +148,21 @@ 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
|
|
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`.
|
|
165
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.
|
|
@@ -176,6 +179,13 @@ and sends a system notification right then — **no RiNG board needs to be open*
|
|
|
176
179
|
after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
|
|
177
180
|
also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
|
|
178
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
|
+
|
|
179
189
|
For clickable notifications on macOS, install `terminal-notifier`:
|
|
180
190
|
|
|
181
191
|
```sh
|
|
@@ -363,6 +373,7 @@ legend = true
|
|
|
363
373
|
active_window_seconds = 21600
|
|
364
374
|
working_threshold_seconds = 90
|
|
365
375
|
waiting_window_seconds = 1800
|
|
376
|
+
idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
|
|
366
377
|
notify_sound = true
|
|
367
378
|
notify_sound_name = "Glass"
|
|
368
379
|
notify_ignore_dnd = false
|
|
@@ -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,19 +123,21 @@ 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
|
|
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`.
|
|
140
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.
|
|
@@ -151,6 +154,13 @@ and sends a system notification right then — **no RiNG board needs to be open*
|
|
|
151
154
|
after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
|
|
152
155
|
also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
|
|
153
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
|
+
|
|
154
164
|
For clickable notifications on macOS, install `terminal-notifier`:
|
|
155
165
|
|
|
156
166
|
```sh
|
|
@@ -338,6 +348,7 @@ legend = true
|
|
|
338
348
|
active_window_seconds = 21600
|
|
339
349
|
working_threshold_seconds = 90
|
|
340
350
|
waiting_window_seconds = 1800
|
|
351
|
+
idle_threshold_seconds = 7200 # 🟡 remind once idle for this long (default 2h; 0 = off)
|
|
341
352
|
notify_sound = true
|
|
342
353
|
notify_sound_name = "Glass"
|
|
343
354
|
notify_ignore_dnd = false
|
|
@@ -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.
|
|
5
|
+
version = "0.8.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"
|
|
@@ -6,7 +6,9 @@ session 需要你回話時,它「響鈴」叫你。
|
|
|
6
6
|
|
|
7
7
|
from importlib.metadata import PackageNotFoundError, version
|
|
8
8
|
|
|
9
|
+
__distribution__ = "ring-cli"
|
|
10
|
+
|
|
9
11
|
try:
|
|
10
|
-
__version__ = version(
|
|
12
|
+
__version__ = version(__distribution__)
|
|
11
13
|
except PackageNotFoundError: # 直接從原始碼跑、尚未安裝
|
|
12
14
|
__version__ = "0.0.0+dev"
|
|
@@ -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(
|
|
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[
|
|
230
|
-
w_proj = max(len(c_proj), *(len(r[
|
|
231
|
-
w_prog = max(len(c_prog), *(len(r[
|
|
232
|
-
w_ago = max(len(c_idle), 3, *(len(r[
|
|
233
|
-
w_loc = max(len(c_loc), *(len(r[
|
|
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" {
|
|
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
|
-
#
|
|
392
|
-
# watch
|
|
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 {
|
|
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"
|
|
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()
|
|
@@ -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
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
active_window_seconds = 21600 # 只看最近這段時間動過的 session(預設 6h)
|
|
10
10
|
working_threshold_seconds = 90 # 多久沒動就從 🟢 工作中 變 🟡 閒置
|
|
11
11
|
waiting_window_seconds = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
|
|
12
|
+
idle_threshold_seconds = 7200 # 🟡 閒置多久後提醒一次(預設 2h;0 = 關閉)
|
|
12
13
|
notify_sound = true # 系統通知帶聲音
|
|
13
14
|
notify_sound_name = "Glass" # macOS / terminal-notifier sound name
|
|
14
15
|
notify_ignore_dnd = false # terminal-notifier 是否加 -ignoreDnD(穿透勿擾 / Focus)
|
|
@@ -58,6 +59,7 @@ class Config:
|
|
|
58
59
|
active_window_seconds: int = 6 * 60 * 60
|
|
59
60
|
working_threshold_seconds: int = 90
|
|
60
61
|
waiting_window_seconds: int = 1800 # 跑完停著升等你的時間窗上限(預設 30 分)
|
|
62
|
+
idle_threshold_seconds: int = 2 * 60 * 60 # 🟡 閒置多久後提醒一次;0 = 關閉
|
|
61
63
|
notify_sound: bool = True
|
|
62
64
|
notify_sound_name: str = "Glass"
|
|
63
65
|
notify_ignore_dnd: bool = False
|
|
@@ -131,6 +133,7 @@ def load(path: Path | None = None) -> Config:
|
|
|
131
133
|
active_window_seconds=_as_int(raw.get("active_window_seconds"), d.active_window_seconds),
|
|
132
134
|
working_threshold_seconds=_as_int(raw.get("working_threshold_seconds"), d.working_threshold_seconds),
|
|
133
135
|
waiting_window_seconds=_as_int(raw.get("waiting_window_seconds"), d.waiting_window_seconds),
|
|
136
|
+
idle_threshold_seconds=max(0, _as_int(raw.get("idle_threshold_seconds"), d.idle_threshold_seconds)),
|
|
134
137
|
notify_sound=_as_bool(raw.get("notify_sound"), d.notify_sound),
|
|
135
138
|
notify_sound_name=(
|
|
136
139
|
raw["notify_sound_name"] if isinstance(raw.get("notify_sound_name"), str) else d.notify_sound_name
|
|
@@ -202,6 +205,7 @@ _SETTERS: dict[str, Callable[[str], object]] = {
|
|
|
202
205
|
"active_window_seconds": _coerce_int,
|
|
203
206
|
"working_threshold_seconds": _coerce_int,
|
|
204
207
|
"waiting_window_seconds": _coerce_int,
|
|
208
|
+
"idle_threshold_seconds": _coerce_int,
|
|
205
209
|
"notify_sound": _coerce_bool,
|
|
206
210
|
"notify_sound_name": str,
|
|
207
211
|
"notify_ignore_dnd": _coerce_bool,
|