ring-cli 0.2.0__tar.gz → 0.4.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.
- {ring_cli-0.2.0 → ring_cli-0.4.0}/PKG-INFO +31 -5
- {ring_cli-0.2.0 → ring_cli-0.4.0}/README.en.md +30 -4
- {ring_cli-0.2.0 → ring_cli-0.4.0}/pyproject.toml +1 -1
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/cli.py +48 -222
- ring_cli-0.4.0/src/ring/commands/__init__.py +1 -0
- ring_cli-0.4.0/src/ring/commands/_args.py +20 -0
- ring_cli-0.4.0/src/ring/commands/doctor.py +122 -0
- ring_cli-0.4.0/src/ring/commands/focus.py +23 -0
- ring_cli-0.4.0/src/ring/commands/gc.py +46 -0
- ring_cli-0.4.0/src/ring/commands/hook.py +29 -0
- ring_cli-0.4.0/src/ring/gc.py +152 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/hook.py +41 -4
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/registry.py +4 -2
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/tui.py +2 -7
- {ring_cli-0.2.0 → ring_cli-0.4.0}/LICENSE +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/config.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/labels.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/locale/en/LC_MESSAGES/ring.po +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/locale/ring.pot +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/__init__.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.2.0 → ring_cli-0.4.0}/src/ring/watcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ring-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -27,6 +27,10 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
|
|
28
28
|
[台灣漢語](https://github.com/Lee-W/ring/blob/main/README.md) · **English**
|
|
29
29
|
|
|
30
|
+
[](https://pypi.org/project/ring-cli/)
|
|
31
|
+
[](https://pypi.org/project/ring-cli/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
30
34
|
> **R**ealtime **I**nstance **N**otification **G**rid
|
|
31
35
|
> — a local dashboard for active agent-CLI sessions. Claude Code and Codex are built in; other tools can plug in.
|
|
32
36
|
|
|
@@ -54,7 +58,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
|
|
|
54
58
|
- **One board for every session**: Claude Code / Codex are built in; other tools can feed `ring hook`.
|
|
55
59
|
- **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
|
|
56
60
|
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, or Terminal.app.
|
|
57
|
-
- **
|
|
61
|
+
- **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
|
|
58
62
|
- **Name your sessions**: press `n` in the TUI to add a local label such as `maigo · auth refactor`.
|
|
59
63
|
- **Local and extensible**: RiNG only reads local Claude Code / Codex data and writes `~/.config/ring/`; session sources, focusers, and notifiers are pluggable.
|
|
60
64
|
|
|
@@ -93,6 +97,7 @@ Zero-config mode can discover local Claude Code / Codex sessions without setup.
|
|
|
93
97
|
ring install-hooks # merges into Claude Code / Codex hook settings
|
|
94
98
|
ring install-hooks --dry-run # preview without writing
|
|
95
99
|
ring doctor # inspect hooks, notification backends, focusers, and config
|
|
100
|
+
ring gc --dry-run # preview RiNG-owned stale state cleanup
|
|
96
101
|
```
|
|
97
102
|
|
|
98
103
|
Hooks only apply to new sessions, so restart Claude Code / Codex sessions after installing.
|
|
@@ -112,6 +117,8 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
|
|
|
112
117
|
| `ring config` | Show config path and effective settings |
|
|
113
118
|
| `ring config set KEY VALUE` | Write one config value |
|
|
114
119
|
| `ring doctor` | Read-only environment diagnosis |
|
|
120
|
+
| `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
|
|
121
|
+
| `ring gc` | Clean RiNG-owned stale state files |
|
|
115
122
|
|
|
116
123
|
## Watch Mode
|
|
117
124
|
|
|
@@ -132,8 +139,10 @@ one live Codex session per cwd can jump correctly; multiple live Codex sessions
|
|
|
132
139
|
|
|
133
140
|
### Notifications
|
|
134
141
|
|
|
135
|
-
Default behavior: with hooks installed,
|
|
136
|
-
a system notification
|
|
142
|
+
Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
|
|
143
|
+
and sends a system notification right then — **no RiNG board needs to be open**, so it rings you even
|
|
144
|
+
after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
|
|
145
|
+
also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
|
|
137
146
|
|
|
138
147
|
For clickable notifications on macOS, install `terminal-notifier`:
|
|
139
148
|
|
|
@@ -145,6 +154,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
|
|
|
145
154
|
Notification sound, repeat timing, and backend selection are configurable; notification backends
|
|
146
155
|
are also pluggable via the `Notifier` extension point.
|
|
147
156
|
|
|
157
|
+
### Cleaning RiNG State Files (`ring gc`)
|
|
158
|
+
|
|
159
|
+
When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
|
|
160
|
+
final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
|
|
161
|
+
the board by default, and you can remove them with `ring gc`.
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
ring gc --dry-run # preview what would be deleted
|
|
165
|
+
ring gc # delete ended registry files older than 7 days, plus expired IPC files
|
|
166
|
+
ring gc --older-than 1d # use a 1-day threshold
|
|
167
|
+
ring gc --all-ended # delete every registry file currently classified as ended
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
|
|
171
|
+
Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
|
|
172
|
+
|
|
148
173
|
## Session Sources
|
|
149
174
|
|
|
150
175
|
RiNG collects sessions from registered sources. Built-ins:
|
|
@@ -243,7 +268,8 @@ send `requires_action = true/false` or `waiting_for = "permission" | "options" |
|
|
|
243
268
|
|
|
244
269
|
### agent-hooks
|
|
245
270
|
|
|
246
|
-
Notification details:
|
|
271
|
+
Notification details: system notifications are sent by `ring hook` at the event (both `--watch` and
|
|
272
|
+
the TUI only render the board; they no longer send system notifications). The default backend is `auto`:
|
|
247
273
|
RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on macOS or `notify-send`
|
|
248
274
|
on Linux. If none are available, RiNG keeps the board running and skips notifications.
|
|
249
275
|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[台灣漢語](https://github.com/Lee-W/ring/blob/main/README.md) · **English**
|
|
4
4
|
|
|
5
|
+
[](https://pypi.org/project/ring-cli/)
|
|
6
|
+
[](https://pypi.org/project/ring-cli/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
5
9
|
> **R**ealtime **I**nstance **N**otification **G**rid
|
|
6
10
|
> — a local dashboard for active agent-CLI sessions. Claude Code and Codex are built in; other tools can plug in.
|
|
7
11
|
|
|
@@ -29,7 +33,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
|
|
|
29
33
|
- **One board for every session**: Claude Code / Codex are built in; other tools can feed `ring hook`.
|
|
30
34
|
- **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
|
|
31
35
|
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, or Terminal.app.
|
|
32
|
-
- **
|
|
36
|
+
- **Notifies you without the board open**: with hooks installed, the moment a session turns 🔴 waiting it beeps and fires a system notification — even with no RiNG board running. With `terminal-notifier`, clicking the notification jumps back.
|
|
33
37
|
- **Name your sessions**: press `n` in the TUI to add a local label such as `maigo · auth refactor`.
|
|
34
38
|
- **Local and extensible**: RiNG only reads local Claude Code / Codex data and writes `~/.config/ring/`; session sources, focusers, and notifiers are pluggable.
|
|
35
39
|
|
|
@@ -68,6 +72,7 @@ Zero-config mode can discover local Claude Code / Codex sessions without setup.
|
|
|
68
72
|
ring install-hooks # merges into Claude Code / Codex hook settings
|
|
69
73
|
ring install-hooks --dry-run # preview without writing
|
|
70
74
|
ring doctor # inspect hooks, notification backends, focusers, and config
|
|
75
|
+
ring gc --dry-run # preview RiNG-owned stale state cleanup
|
|
71
76
|
```
|
|
72
77
|
|
|
73
78
|
Hooks only apply to new sessions, so restart Claude Code / Codex sessions after installing.
|
|
@@ -87,6 +92,8 @@ Hooks only apply to new sessions, so restart Claude Code / Codex sessions after
|
|
|
87
92
|
| `ring config` | Show config path and effective settings |
|
|
88
93
|
| `ring config set KEY VALUE` | Write one config value |
|
|
89
94
|
| `ring doctor` | Read-only environment diagnosis |
|
|
95
|
+
| `ring gc --dry-run` | Preview RiNG-owned stale state cleanup |
|
|
96
|
+
| `ring gc` | Clean RiNG-owned stale state files |
|
|
90
97
|
|
|
91
98
|
## Watch Mode
|
|
92
99
|
|
|
@@ -107,8 +114,10 @@ one live Codex session per cwd can jump correctly; multiple live Codex sessions
|
|
|
107
114
|
|
|
108
115
|
### Notifications
|
|
109
116
|
|
|
110
|
-
Default behavior: with hooks installed,
|
|
111
|
-
a system notification
|
|
117
|
+
Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
|
|
118
|
+
and sends a system notification right then — **no RiNG board needs to be open**, so it rings you even
|
|
119
|
+
after you close the terminal (notifications are event-driven, not polled). While a TUI is open, it
|
|
120
|
+
also nudges you again in-app at 30s / 120s / 300s if the session keeps waiting.
|
|
112
121
|
|
|
113
122
|
For clickable notifications on macOS, install `terminal-notifier`:
|
|
114
123
|
|
|
@@ -120,6 +129,22 @@ Without it, RiNG falls back to macOS text notifications without click-to-focus.
|
|
|
120
129
|
Notification sound, repeat timing, and backend selection are configurable; notification backends
|
|
121
130
|
are also pluggable via the `Notifier` extension point.
|
|
122
131
|
|
|
132
|
+
### Cleaning RiNG State Files (`ring gc`)
|
|
133
|
+
|
|
134
|
+
When RiNG receives `SessionEnd`, it removes its own hook registry entry. If an agent crashes or the
|
|
135
|
+
final hook does not run, ended `~/.config/ring/sessions/*.json` files can remain. They are hidden from
|
|
136
|
+
the board by default, and you can remove them with `ring gc`.
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
ring gc --dry-run # preview what would be deleted
|
|
140
|
+
ring gc # delete ended registry files older than 7 days, plus expired IPC files
|
|
141
|
+
ring gc --older-than 1d # use a 1-day threshold
|
|
142
|
+
ring gc --all-ended # delete every registry file currently classified as ended
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`ring gc` only cleans state files RiNG owns under `~/.config/ring/`. It does not touch Claude Code /
|
|
146
|
+
Codex transcripts or state. `ring doctor` remains read-only and never deletes files.
|
|
147
|
+
|
|
123
148
|
## Session Sources
|
|
124
149
|
|
|
125
150
|
RiNG collects sessions from registered sources. Built-ins:
|
|
@@ -218,7 +243,8 @@ send `requires_action = true/false` or `waiting_for = "permission" | "options" |
|
|
|
218
243
|
|
|
219
244
|
### agent-hooks
|
|
220
245
|
|
|
221
|
-
Notification details:
|
|
246
|
+
Notification details: system notifications are sent by `ring hook` at the event (both `--watch` and
|
|
247
|
+
the TUI only render the board; they no longer send system notifications). The default backend is `auto`:
|
|
222
248
|
RiNG prefers clickable `terminal-notifier`, then falls back to `osascript` on macOS or `notify-send`
|
|
223
249
|
on Linux. If none are available, RiNG keeps the board running and skips notifications.
|
|
224
250
|
|
|
@@ -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.4.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"
|
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
|
-
import shutil
|
|
14
13
|
import sys
|
|
15
14
|
import time
|
|
15
|
+
from dataclasses import fields
|
|
16
16
|
from importlib.util import find_spec
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
19
|
from ring import __version__
|
|
20
|
+
from ring.commands._args import strip_lang as _strip_lang
|
|
21
|
+
from ring.commands.doctor import run_doctor
|
|
22
|
+
from ring.commands.focus import run_focus
|
|
23
|
+
from ring.commands.gc import run_gc
|
|
24
|
+
from ring.commands.hook import run_hook_command, run_install_hooks, run_remove_hooks
|
|
20
25
|
from ring.config import CONFIG_PATH as CONFIG_PATH
|
|
21
26
|
from ring.config import Config as Config
|
|
22
27
|
from ring.config import ConfigError as ConfigError
|
|
@@ -26,7 +31,7 @@ from ring.i18n import gettext as _
|
|
|
26
31
|
from ring.i18n import ngettext, set_lang
|
|
27
32
|
from ring.labels import load_labels
|
|
28
33
|
from ring.registry import Session, Status, running_agent_pids
|
|
29
|
-
from ring.sources import discover_sessions
|
|
34
|
+
from ring.sources import discover_sessions
|
|
30
35
|
|
|
31
36
|
try:
|
|
32
37
|
from rich.box import SIMPLE_HEAD
|
|
@@ -74,14 +79,14 @@ def _middle_truncate(text: str, max_len: int) -> str:
|
|
|
74
79
|
tmux 座標(如 ``main:1.0``)很短,自然走這條,不被動到。
|
|
75
80
|
|
|
76
81
|
2. 否則以 ``/`` 為界,保留最後一層目錄完整:
|
|
77
|
-
``tail = "/
|
|
82
|
+
``tail = f"/{text.rsplit('/', 1)[-1]}"``,
|
|
78
83
|
``head_budget = max_len - 1 - len(tail)``(1 = ``…`` 的長度)。
|
|
79
|
-
若 ``head_budget >= 1``:回傳 ``text[:head_budget]
|
|
84
|
+
若 ``head_budget >= 1``:回傳 ``f"{text[:head_budget]}…{tail}"``。
|
|
80
85
|
|
|
81
86
|
3. ``head_budget < 1``(最後一層目錄名本身就超長,或 text 無 ``/``):
|
|
82
87
|
退化為純字元中段省略——
|
|
83
88
|
``keep = max_len - 1``;``front = (keep + 1) // 2``;``back = keep // 2``;
|
|
84
|
-
回傳 ``text[:front]
|
|
89
|
+
回傳 ``f"{text[:front]}…{text[-back:]}"``(總長 == max_len)。
|
|
85
90
|
|
|
86
91
|
4. ``max_len <= 1`` 邊界:直接回傳 ``text[:max_len]``,避免負數切片。
|
|
87
92
|
|
|
@@ -92,17 +97,17 @@ def _middle_truncate(text: str, max_len: int) -> str:
|
|
|
92
97
|
if len(text) <= max_len:
|
|
93
98
|
return text
|
|
94
99
|
# 以 / 為界,保留最後一層
|
|
95
|
-
tail = "/
|
|
100
|
+
tail = f"/{text.rsplit('/', 1)[-1]}"
|
|
96
101
|
head_budget = max_len - 1 - len(tail) # 1 for "…"
|
|
97
102
|
if head_budget >= 1:
|
|
98
|
-
return text[:head_budget]
|
|
103
|
+
return f"{text[:head_budget]}…{tail}"
|
|
99
104
|
# 病態長尾段 fallback:純字元中段省略
|
|
100
105
|
keep = max_len - 1
|
|
101
106
|
front = (keep + 1) // 2
|
|
102
107
|
back = keep // 2
|
|
103
108
|
if back == 0:
|
|
104
109
|
return text[:max_len]
|
|
105
|
-
return text[:front]
|
|
110
|
+
return f"{text[:front]}…{text[-back:]}"
|
|
106
111
|
|
|
107
112
|
|
|
108
113
|
def board(show_all: bool) -> list[Session]:
|
|
@@ -158,7 +163,7 @@ def _rich_renderable(sessions: list[Session], show_legend: bool, show_tool: bool
|
|
|
158
163
|
if show_legend:
|
|
159
164
|
blocks.append(_rich_legend())
|
|
160
165
|
if not sessions:
|
|
161
|
-
blocks.append(Text("
|
|
166
|
+
blocks.append(Text(f" {_('(場館目前沒人上台)')}", style=f"{_MUTED} italic"))
|
|
162
167
|
return Group(*blocks)
|
|
163
168
|
|
|
164
169
|
table = Table(box=SIMPLE_HEAD, header_style="bold", pad_edge=False, expand=False)
|
|
@@ -196,7 +201,7 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
|
|
|
196
201
|
items = " ".join(f"{st.marker} {status_label(st)}" for st in Status)
|
|
197
202
|
lines += ["", f" {_('圖例')} {items}"]
|
|
198
203
|
if not sessions:
|
|
199
|
-
lines += ["", "
|
|
204
|
+
lines += ["", f" {_('(場館目前沒人上台)')}"]
|
|
200
205
|
return "\n".join(lines)
|
|
201
206
|
|
|
202
207
|
labels = load_labels()
|
|
@@ -219,16 +224,12 @@ def _render_plain(sessions: list[Session], show_legend: bool, show_tool: bool =
|
|
|
219
224
|
w_ago = max(len(c_idle), 3, *(len(r[4]) for r in rows))
|
|
220
225
|
w_loc = max(len(c_loc), *(len(r[5]) for r in rows))
|
|
221
226
|
tool_h = f"{c_tool:<{w_tool}} " if show_tool else ""
|
|
222
|
-
header =
|
|
223
|
-
f" {tool_h}{c_proj:<{w_proj}} {c_prog:>{w_prog}} "
|
|
224
|
-
f"{c_idle:>{w_ago}} {c_loc:<{w_loc}} {c_act}"
|
|
225
|
-
)
|
|
227
|
+
header = f" {tool_h}{c_proj:<{w_proj}} {c_prog:>{w_prog}} {c_idle:>{w_ago}} {c_loc:<{w_loc}} {c_act}"
|
|
226
228
|
lines += ["", header]
|
|
227
229
|
for marker, tool, project, prog, ago, loc, action in rows:
|
|
228
230
|
tool_c = f"{tool:<{w_tool}} " if show_tool else ""
|
|
229
231
|
lines.append(
|
|
230
|
-
f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} "
|
|
231
|
-
f"{ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
|
|
232
|
+
f" {marker} {tool_c}{project:<{w_proj}} {prog:>{w_prog}} {ago:>{w_ago}} 📍{loc:<{w_loc}} {action}"
|
|
232
233
|
)
|
|
233
234
|
return "\n".join(lines)
|
|
234
235
|
|
|
@@ -247,7 +248,7 @@ def _format_config_value(value: object) -> str:
|
|
|
247
248
|
if value is None:
|
|
248
249
|
return "—"
|
|
249
250
|
if isinstance(value, tuple):
|
|
250
|
-
return "[
|
|
251
|
+
return f"[{', '.join(str(v) for v in value)}]" if value else _("(內建預設)")
|
|
251
252
|
if isinstance(value, dict):
|
|
252
253
|
return ", ".join(f"{k}={v}" for k, v in value.items())
|
|
253
254
|
return str(value)
|
|
@@ -259,8 +260,6 @@ def print_config() -> None:
|
|
|
259
260
|
欄位直接從 ``Config`` dataclass 列舉,所以新增設定不必再動這裡。值跟內建預設
|
|
260
261
|
不同的會標一個箭頭,讓你一眼看出「我改過哪些」。
|
|
261
262
|
"""
|
|
262
|
-
from dataclasses import fields
|
|
263
|
-
|
|
264
263
|
cfg = get_config()
|
|
265
264
|
defaults = Config()
|
|
266
265
|
exists = CONFIG_PATH.exists()
|
|
@@ -278,13 +277,11 @@ def print_config() -> None:
|
|
|
278
277
|
print(f" {f.name:<{width}} {_format_config_value(value)}{mark}")
|
|
279
278
|
print()
|
|
280
279
|
hint = _("用 `ring config set KEY VALUE` 改,或直接編輯上面那個檔;完整選項見 src/ring/config.py 的 docstring。")
|
|
281
|
-
print(" "
|
|
280
|
+
print(f" {hint}")
|
|
282
281
|
|
|
283
282
|
|
|
284
283
|
def _config_get_value(key: str) -> object:
|
|
285
284
|
"""讀單一設定的目前生效值(支援 colors.<name> 點記法)。未知鍵丟 ConfigError。"""
|
|
286
|
-
from dataclasses import fields
|
|
287
|
-
|
|
288
285
|
cfg = get_config()
|
|
289
286
|
if "." in key:
|
|
290
287
|
table, sub = key.split(".", 1)
|
|
@@ -296,23 +293,6 @@ def _config_get_value(key: str) -> object:
|
|
|
296
293
|
raise ConfigError(_("未知的鍵:{key}", key=key))
|
|
297
294
|
|
|
298
295
|
|
|
299
|
-
def _strip_lang(args: list[str]) -> list[str]:
|
|
300
|
-
"""濾掉全域 ``--lang`` 旗標(已在 main 先 peek 過),只留 config 自己的位置參數。"""
|
|
301
|
-
out: list[str] = []
|
|
302
|
-
skip = False
|
|
303
|
-
for i, a in enumerate(args):
|
|
304
|
-
if skip:
|
|
305
|
-
skip = False
|
|
306
|
-
continue
|
|
307
|
-
if a == "--lang":
|
|
308
|
-
skip = i + 1 < len(args) # 連同它的值一起跳過
|
|
309
|
-
continue
|
|
310
|
-
if a.startswith("--lang="):
|
|
311
|
-
continue
|
|
312
|
-
out.append(a)
|
|
313
|
-
return out
|
|
314
|
-
|
|
315
|
-
|
|
316
296
|
def run_config(args: list[str]) -> int:
|
|
317
297
|
"""``ring config`` 進入點:無參數→列表;``get KEY``→讀;``set KEY VALUE``→寫。"""
|
|
318
298
|
args = _strip_lang(args)
|
|
@@ -343,158 +323,25 @@ def run_config(args: list[str]) -> int:
|
|
|
343
323
|
print(f"⚠️ {e}", file=sys.stderr)
|
|
344
324
|
return 1
|
|
345
325
|
print(_("✅ 已設定 {key} = {value}({path})", key=key, value=_format_config_value(coerced), path=CONFIG_PATH))
|
|
346
|
-
print("
|
|
326
|
+
print(f" {_('註:set 會重寫整個設定檔,原有註解不會保留。')}")
|
|
347
327
|
return 0
|
|
348
328
|
|
|
349
329
|
print(_("未知的 config 動作:{action}(用 get / set,或不帶參數看目前設定)", action=action), file=sys.stderr)
|
|
350
330
|
return 2
|
|
351
331
|
|
|
352
332
|
|
|
353
|
-
def run_doctor(args: list[str]) -> int:
|
|
354
|
-
"""``ring doctor`` 進入點:唯讀環境診斷,印出五節報告,固定回 0。args 非空回 2。"""
|
|
355
|
-
args = _strip_lang(args)
|
|
356
|
-
if args:
|
|
357
|
-
print(_("用法:ring doctor"), file=sys.stderr)
|
|
358
|
-
return 2
|
|
359
|
-
|
|
360
|
-
from ring.focus import focusers
|
|
361
|
-
from ring.hook import hook_status
|
|
362
|
-
from ring.notify import _select_notifier, notifiers
|
|
363
|
-
from ring.osascript import osascript
|
|
364
|
-
|
|
365
|
-
cfg = get_config()
|
|
366
|
-
|
|
367
|
-
print(_("RiNG 環境診斷"))
|
|
368
|
-
print(f" {_('狀態')}:{_('唯讀檢查,不會改動任何設定')}")
|
|
369
|
-
print()
|
|
370
|
-
|
|
371
|
-
# ── (a) Session 來源 ────────────────────────────────────────────────────
|
|
372
|
-
print(_("Session 來源"))
|
|
373
|
-
src_list = sources()
|
|
374
|
-
if src_list:
|
|
375
|
-
width_src = max(len(s.name) for s in src_list)
|
|
376
|
-
else:
|
|
377
|
-
width_src = 10
|
|
378
|
-
for src in src_list:
|
|
379
|
-
try:
|
|
380
|
-
found = src.discover()
|
|
381
|
-
n = len(found)
|
|
382
|
-
status_str = _("活著")
|
|
383
|
-
count_str = _("偵測到 {n} 個 session", n=n)
|
|
384
|
-
print(f" {src.name:<{width_src}} {status_str} {count_str}")
|
|
385
|
-
except Exception:
|
|
386
|
-
print(f" {src.name:<{width_src}} {_('偵測失敗')}")
|
|
387
|
-
print()
|
|
388
|
-
|
|
389
|
-
# ── (b) Hook 安裝 ────────────────────────────────────────────────────────
|
|
390
|
-
print(_("Hook 安裝"))
|
|
391
|
-
statuses = hook_status()
|
|
392
|
-
provider_labels = {"claude-code": "Claude Code", "codex": "Codex"}
|
|
393
|
-
if statuses:
|
|
394
|
-
width_hook = max(len(provider_labels.get(s.provider, s.provider)) for s in statuses)
|
|
395
|
-
else:
|
|
396
|
-
width_hook = 10
|
|
397
|
-
for hs in statuses:
|
|
398
|
-
label = provider_labels.get(hs.provider, hs.provider)
|
|
399
|
-
if not hs.applicable:
|
|
400
|
-
msg = _("未使用 Codex(zero-config)")
|
|
401
|
-
elif hs.installed:
|
|
402
|
-
msg = _("已安裝")
|
|
403
|
-
else:
|
|
404
|
-
msg = _("未安裝(執行 ring install-hooks)")
|
|
405
|
-
print(f" {label:<{width_hook}} {msg}")
|
|
406
|
-
print()
|
|
407
|
-
|
|
408
|
-
# ── (c) 通知後端 ─────────────────────────────────────────────────────────
|
|
409
|
-
print(_("通知後端"))
|
|
410
|
-
print(f" {_('目前設定')}:{cfg.notify_backend}")
|
|
411
|
-
notifier_list = notifiers()
|
|
412
|
-
if notifier_list:
|
|
413
|
-
width_n = max(len(nt.name) for nt in notifier_list)
|
|
414
|
-
else:
|
|
415
|
-
width_n = 10
|
|
416
|
-
for nt in notifier_list:
|
|
417
|
-
avail_str = _("可用") if nt.available() else _("不可用")
|
|
418
|
-
print(f" {nt.name:<{width_n}} {avail_str}")
|
|
419
|
-
selected = _select_notifier(cfg.notify_backend)
|
|
420
|
-
if selected is not None:
|
|
421
|
-
print(f" {_('auto 實際選中')}:{selected.name}")
|
|
422
|
-
if sys.platform == "darwin" and selected.name in {"terminal-notifier", "osascript"}:
|
|
423
|
-
print(
|
|
424
|
-
" "
|
|
425
|
-
+ _(
|
|
426
|
-
"macOS 提醒:若只聽到聲音但沒有通知框,請到系統設定的通知項目啟用 Banner/Alert。"
|
|
427
|
-
)
|
|
428
|
-
)
|
|
429
|
-
else:
|
|
430
|
-
# 附原因
|
|
431
|
-
if cfg.notify_backend == "none":
|
|
432
|
-
reason = _("backend=none")
|
|
433
|
-
elif cfg.notify_backend == "agent-hooks" and shutil.which("agent-hooks") is not None:
|
|
434
|
-
reason = _("agent-hooks 已接手")
|
|
435
|
-
else:
|
|
436
|
-
reason = _("全部不可用")
|
|
437
|
-
print(f" {_('auto 實際選中')}:{_('不發通知')}({reason})")
|
|
438
|
-
print()
|
|
439
|
-
|
|
440
|
-
# ── (d) 聚焦終端(focuser)───────────────────────────────────────────────
|
|
441
|
-
print(_("聚焦終端(focuser)"))
|
|
442
|
-
focuser_list = focusers()
|
|
443
|
-
if focuser_list:
|
|
444
|
-
width_f = max(len(f.name) for f in focuser_list)
|
|
445
|
-
else:
|
|
446
|
-
width_f = 10
|
|
447
|
-
for f in focuser_list:
|
|
448
|
-
name_lower = f.name.lower()
|
|
449
|
-
if name_lower == "tmux":
|
|
450
|
-
avail = shutil.which("tmux") is not None
|
|
451
|
-
avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
|
|
452
|
-
else:
|
|
453
|
-
# iTerm2 / Terminal:先確認 osascript 在,再問 app 是否跑著
|
|
454
|
-
if shutil.which("osascript") is None:
|
|
455
|
-
avail_str = _("不可用(osascript 不在 PATH)")
|
|
456
|
-
else:
|
|
457
|
-
app_name = f.name # "iTerm2" or "Terminal"
|
|
458
|
-
try:
|
|
459
|
-
rc, out, _err = osascript(f'application "{app_name}" is running')
|
|
460
|
-
avail_str = _("可用") if (rc == 0 and out == "true") else _("不可用(app 沒在跑)")
|
|
461
|
-
except Exception:
|
|
462
|
-
avail_str = _("不可用(app 沒在跑)")
|
|
463
|
-
print(f" {f.name:<{width_f}} {avail_str}")
|
|
464
|
-
print()
|
|
465
|
-
|
|
466
|
-
# ── (e) 設定檔 ───────────────────────────────────────────────────────────
|
|
467
|
-
print(_("設定檔"))
|
|
468
|
-
exists = CONFIG_PATH.exists()
|
|
469
|
-
print(f" {_('路徑')}:{CONFIG_PATH}")
|
|
470
|
-
print(f" {_('狀態')}:{_('已存在') if exists else _('不存在(全部用內建預設)')}")
|
|
471
|
-
print(f" {_('完整生效值請看 `ring config`。')}")
|
|
472
|
-
|
|
473
|
-
return 0
|
|
474
|
-
|
|
475
|
-
|
|
476
333
|
def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
cfg = get_config()
|
|
334
|
+
# 系統通知由 ``ring hook`` 在 session 轉 🔴 等你的當下就地發出(見 hook._ring_waiting_now);
|
|
335
|
+
# watch 只負責顯示看板,不再輪詢發通知——這樣關掉看板也照樣 ring 你。
|
|
481
336
|
frames = 0
|
|
482
337
|
footer_text = _("每 {interval}s 刷新 · Ctrl-C 離場", interval=int(interval))
|
|
483
338
|
if not HAVE_RICH:
|
|
484
|
-
scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
|
|
485
339
|
try:
|
|
486
340
|
while True:
|
|
487
341
|
sys.stdout.write("\033[2J\033[H")
|
|
488
342
|
sessions = board(show_all)
|
|
489
|
-
alerts = scheduler.feed(sessions)
|
|
490
|
-
try:
|
|
491
|
-
hint = notify_waiting(alerts)
|
|
492
|
-
if hint:
|
|
493
|
-
print(hint)
|
|
494
|
-
except Exception:
|
|
495
|
-
pass
|
|
496
343
|
print(_render_plain(sessions, show_legend, show_tool_column(sessions)))
|
|
497
|
-
print("\n"
|
|
344
|
+
print(f"\n{footer_text}")
|
|
498
345
|
sys.stdout.flush()
|
|
499
346
|
frames += 1
|
|
500
347
|
if count and frames >= count:
|
|
@@ -504,20 +351,12 @@ def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int
|
|
|
504
351
|
return 0
|
|
505
352
|
|
|
506
353
|
console = Console()
|
|
507
|
-
scheduler = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
|
|
508
354
|
try:
|
|
509
355
|
with Live(console=console, screen=True, auto_refresh=False) as live:
|
|
510
356
|
while True:
|
|
511
357
|
sessions = board(show_all)
|
|
512
|
-
alerts = scheduler.feed(sessions)
|
|
513
|
-
try:
|
|
514
|
-
hint = notify_waiting(alerts)
|
|
515
|
-
if hint:
|
|
516
|
-
print(hint)
|
|
517
|
-
except Exception:
|
|
518
|
-
pass
|
|
519
358
|
body = _rich_renderable(sessions, show_legend, show_tool_column(sessions))
|
|
520
|
-
live.update(Group(body, Text("\n"
|
|
359
|
+
live.update(Group(body, Text(f"\n{footer_text}", style=_MUTED)), refresh=True)
|
|
521
360
|
frames += 1
|
|
522
361
|
if count and frames >= count:
|
|
523
362
|
return 0
|
|
@@ -548,7 +387,8 @@ commands:
|
|
|
548
387
|
config get KEY 讀單一設定的目前值
|
|
549
388
|
config set KEY VALUE 寫入單一設定(會重寫設定檔,不保留註解)
|
|
550
389
|
focus SESSION_ID 聚焦指定 session;TUI 在跑時會回到 RiNG 並選中該列
|
|
551
|
-
|
|
390
|
+
gc [--dry-run] 清理 RiNG 自己的 stale 狀態檔
|
|
391
|
+
doctor 顯示環境診斷(唯讀)——hook、通知、focuser、維護提示
|
|
552
392
|
"""
|
|
553
393
|
)
|
|
554
394
|
|
|
@@ -585,12 +425,23 @@ def _subcommand_help(name: str) -> str:
|
|
|
585
425
|
"""usage: ring focus SESSION_ID
|
|
586
426
|
|
|
587
427
|
聚焦指定 session;若 RiNG TUI 正在執行,會回到 TUI 並選中該列。
|
|
428
|
+
"""
|
|
429
|
+
),
|
|
430
|
+
"gc": _(
|
|
431
|
+
"""usage: ring gc [--dry-run] [--older-than DURATION] [--all-ended]
|
|
432
|
+
|
|
433
|
+
清理 RiNG 自己的 stale 狀態檔;不碰 Claude Code / Codex 的 transcript 或 state。
|
|
434
|
+
|
|
435
|
+
options:
|
|
436
|
+
--dry-run 只預覽,不刪檔
|
|
437
|
+
--older-than DURATION 清理超過指定時間的已離場 registry(例如 30m、2h、7d;預設 7d)
|
|
438
|
+
--all-ended 清理所有目前判定已離場的 registry
|
|
588
439
|
"""
|
|
589
440
|
),
|
|
590
441
|
"doctor": _(
|
|
591
442
|
"""usage: ring doctor
|
|
592
443
|
|
|
593
|
-
唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser
|
|
444
|
+
唯讀環境診斷:逐節報告 hook 安裝狀態、通知後端可用性、focuser 可用性、維護提示與設定檔位置。
|
|
594
445
|
不寫任何檔案、不安裝、不發通知;固定回傳 0。
|
|
595
446
|
"""
|
|
596
447
|
),
|
|
@@ -603,53 +454,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
603
454
|
cfg = get_config()
|
|
604
455
|
set_lang(_peek_lang(raw) or cfg.lang) # 在 import ring.tui 前設好,Footer 按鍵說明也跟著語言
|
|
605
456
|
|
|
606
|
-
if
|
|
607
|
-
|
|
457
|
+
if (
|
|
458
|
+
raw
|
|
459
|
+
and raw[0] in {"hook", "install-hooks", "remove-hooks", "config", "focus", "gc", "doctor"}
|
|
460
|
+
and any(arg in {"-h", "--help"} for arg in raw[1:])
|
|
608
461
|
):
|
|
609
462
|
print(_subcommand_help(raw[0]), end="")
|
|
610
463
|
return 0
|
|
611
464
|
|
|
612
465
|
if raw and raw[0] == "hook":
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
provider = "claude-code"
|
|
616
|
-
hook_args = raw[1:]
|
|
617
|
-
if hook_args:
|
|
618
|
-
if hook_args[0] == "--provider" and len(hook_args) >= 2:
|
|
619
|
-
provider = hook_args[1]
|
|
620
|
-
elif hook_args[0].startswith("--provider="):
|
|
621
|
-
provider = hook_args[0].split("=", 1)[1]
|
|
622
|
-
elif not hook_args[0].startswith("-"):
|
|
623
|
-
provider = hook_args[0]
|
|
624
|
-
return run_hook(provider=provider)
|
|
466
|
+
return run_hook_command(raw[1:])
|
|
625
467
|
if raw and raw[0] == "install-hooks":
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return install_hooks(dry_run="--dry-run" in raw)
|
|
468
|
+
return run_install_hooks(raw[1:])
|
|
629
469
|
if raw and raw[0] == "remove-hooks":
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
return uninstall_hooks(dry_run="--dry-run" in raw)
|
|
470
|
+
return run_remove_hooks(raw[1:])
|
|
633
471
|
if raw and raw[0] == "config":
|
|
634
472
|
return run_config(raw[1:])
|
|
473
|
+
if raw and raw[0] == "gc":
|
|
474
|
+
return run_gc(raw[1:])
|
|
635
475
|
if raw and raw[0] == "doctor":
|
|
636
476
|
return run_doctor(raw[1:])
|
|
637
477
|
if raw and raw[0] == "focus" and len(raw) >= 2:
|
|
638
|
-
|
|
639
|
-
from ring.ipc import read_tui_presence, write_focus_request
|
|
640
|
-
from ring.sources import get_by_id
|
|
641
|
-
|
|
642
|
-
session = get_by_id(raw[1])
|
|
643
|
-
if session is None:
|
|
644
|
-
return 0
|
|
645
|
-
presence = read_tui_presence()
|
|
646
|
-
if presence is not None:
|
|
647
|
-
# TUI 在跑:寫 focus-request,讓 TUI 自己移游標並 activate 視窗。
|
|
648
|
-
write_focus_request(raw[1])
|
|
649
|
-
else:
|
|
650
|
-
# headless(沒有 TUI 在跑):退化回現行行為——直接跳到 claude 所在終端。
|
|
651
|
-
focus_jump(session)
|
|
652
|
-
return 0
|
|
478
|
+
return run_focus(raw[1:])
|
|
653
479
|
|
|
654
480
|
parser = argparse.ArgumentParser(
|
|
655
481
|
prog="ring",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI 子命令 handlers。"""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""CLI argument helpers shared by subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def strip_lang(args: list[str]) -> list[str]:
|
|
7
|
+
"""濾掉全域 ``--lang`` 旗標(已在 main 先 peek 過),只留子命令自己的參數。"""
|
|
8
|
+
out: list[str] = []
|
|
9
|
+
skip = False
|
|
10
|
+
for i, a in enumerate(args):
|
|
11
|
+
if skip:
|
|
12
|
+
skip = False
|
|
13
|
+
continue
|
|
14
|
+
if a == "--lang":
|
|
15
|
+
skip = i + 1 < len(args)
|
|
16
|
+
continue
|
|
17
|
+
if a.startswith("--lang="):
|
|
18
|
+
continue
|
|
19
|
+
out.append(a)
|
|
20
|
+
return out
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""``ring doctor`` command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ring.commands._args import strip_lang
|
|
9
|
+
from ring.config import CONFIG_PATH, get_config
|
|
10
|
+
from ring.gc import DEFAULT_OLDER_THAN_SECONDS
|
|
11
|
+
from ring.gc import collect_candidates as gc_collect_candidates
|
|
12
|
+
from ring.i18n import gettext as _
|
|
13
|
+
from ring.sources import sources
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_doctor(args: list[str]) -> int:
|
|
17
|
+
"""唯讀環境診斷,印出各節報告,固定回 0。args 非空回 2。"""
|
|
18
|
+
args = strip_lang(args)
|
|
19
|
+
if args:
|
|
20
|
+
print(_("用法:ring doctor"), file=sys.stderr)
|
|
21
|
+
return 2
|
|
22
|
+
|
|
23
|
+
from ring.focus import focusers
|
|
24
|
+
from ring.hook import hook_status
|
|
25
|
+
from ring.notify import _select_notifier, notifiers
|
|
26
|
+
from ring.osascript import osascript
|
|
27
|
+
|
|
28
|
+
cfg = get_config()
|
|
29
|
+
|
|
30
|
+
print(_("RiNG 環境診斷"))
|
|
31
|
+
print(f" {_('狀態')}:{_('唯讀檢查,不會改動任何設定')}")
|
|
32
|
+
print()
|
|
33
|
+
|
|
34
|
+
print(_("Session 來源"))
|
|
35
|
+
src_list = sources()
|
|
36
|
+
width_src = max(len(s.name) for s in src_list) if src_list else 10
|
|
37
|
+
for src in src_list:
|
|
38
|
+
try:
|
|
39
|
+
found = src.discover()
|
|
40
|
+
n = len(found)
|
|
41
|
+
status_str = _("活著")
|
|
42
|
+
count_str = _("偵測到 {n} 個 session", n=n)
|
|
43
|
+
print(f" {src.name:<{width_src}} {status_str} {count_str}")
|
|
44
|
+
except Exception:
|
|
45
|
+
print(f" {src.name:<{width_src}} {_('偵測失敗')}")
|
|
46
|
+
print()
|
|
47
|
+
|
|
48
|
+
print(_("Hook 安裝"))
|
|
49
|
+
statuses = hook_status()
|
|
50
|
+
provider_labels = {"claude-code": "Claude Code", "codex": "Codex"}
|
|
51
|
+
width_hook = max(len(provider_labels.get(s.provider, s.provider)) for s in statuses) if statuses else 10
|
|
52
|
+
for hs in statuses:
|
|
53
|
+
label = provider_labels.get(hs.provider, hs.provider)
|
|
54
|
+
if not hs.applicable:
|
|
55
|
+
msg = _("未使用 Codex(zero-config)")
|
|
56
|
+
elif hs.installed:
|
|
57
|
+
msg = _("已安裝")
|
|
58
|
+
else:
|
|
59
|
+
msg = _("未安裝(執行 ring install-hooks)")
|
|
60
|
+
print(f" {label:<{width_hook}} {msg}")
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
print(_("通知後端"))
|
|
64
|
+
print(f" {_('目前設定')}:{cfg.notify_backend}")
|
|
65
|
+
notifier_list = notifiers()
|
|
66
|
+
width_n = max(len(nt.name) for nt in notifier_list) if notifier_list else 10
|
|
67
|
+
for nt in notifier_list:
|
|
68
|
+
avail_str = _("可用") if nt.available() else _("不可用")
|
|
69
|
+
print(f" {nt.name:<{width_n}} {avail_str}")
|
|
70
|
+
selected = _select_notifier(cfg.notify_backend)
|
|
71
|
+
if selected is not None:
|
|
72
|
+
print(f" {_('auto 實際選中')}:{selected.name}")
|
|
73
|
+
if sys.platform == "darwin" and selected.name in {"terminal-notifier", "osascript"}:
|
|
74
|
+
print(f" {_('macOS 提醒:若只聽到聲音但沒有通知框,請到系統設定的通知項目啟用 Banner/Alert。')}")
|
|
75
|
+
else:
|
|
76
|
+
if cfg.notify_backend == "none":
|
|
77
|
+
reason = _("backend=none")
|
|
78
|
+
elif cfg.notify_backend == "agent-hooks" and shutil.which("agent-hooks") is not None:
|
|
79
|
+
reason = _("agent-hooks 已接手")
|
|
80
|
+
else:
|
|
81
|
+
reason = _("全部不可用")
|
|
82
|
+
print(f" {_('auto 實際選中')}:{_('不發通知')}({reason})")
|
|
83
|
+
print()
|
|
84
|
+
|
|
85
|
+
print(_("聚焦終端(focuser)"))
|
|
86
|
+
focuser_list = focusers()
|
|
87
|
+
width_f = max(len(f.name) for f in focuser_list) if focuser_list else 10
|
|
88
|
+
for f in focuser_list:
|
|
89
|
+
name_lower = f.name.lower()
|
|
90
|
+
if name_lower == "tmux":
|
|
91
|
+
avail = shutil.which("tmux") is not None
|
|
92
|
+
avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
|
|
93
|
+
elif shutil.which("osascript") is None:
|
|
94
|
+
avail_str = _("不可用(osascript 不在 PATH)")
|
|
95
|
+
else:
|
|
96
|
+
app_name = f.name
|
|
97
|
+
try:
|
|
98
|
+
rc, out, _err = osascript(f'application "{app_name}" is running')
|
|
99
|
+
avail_str = _("可用") if (rc == 0 and out == "true") else _("不可用(app 沒在跑)")
|
|
100
|
+
except Exception:
|
|
101
|
+
avail_str = _("不可用(app 沒在跑)")
|
|
102
|
+
print(f" {f.name:<{width_f}} {avail_str}")
|
|
103
|
+
print()
|
|
104
|
+
|
|
105
|
+
print(_("維護"))
|
|
106
|
+
try:
|
|
107
|
+
candidates = gc_collect_candidates(older_than=DEFAULT_OLDER_THAN_SECONDS)
|
|
108
|
+
if candidates:
|
|
109
|
+
print(f" {_('可清理')}:{_('{n} 個 RiNG stale 狀態檔(執行 ring gc --dry-run 預覽)', n=len(candidates))}")
|
|
110
|
+
else:
|
|
111
|
+
print(f" {_('可清理')}:{_('沒有 RiNG stale 狀態檔')}")
|
|
112
|
+
except Exception:
|
|
113
|
+
print(f" {_('可清理')}:{_('偵測失敗')}")
|
|
114
|
+
print()
|
|
115
|
+
|
|
116
|
+
print(_("設定檔"))
|
|
117
|
+
exists = CONFIG_PATH.exists()
|
|
118
|
+
print(f" {_('路徑')}:{CONFIG_PATH}")
|
|
119
|
+
print(f" {_('狀態')}:{_('已存在') if exists else _('不存在(全部用內建預設)')}")
|
|
120
|
+
print(f" {_('完整生效值請看 `ring config`。')}")
|
|
121
|
+
|
|
122
|
+
return 0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""``ring focus`` command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_focus(args: list[str]) -> int:
|
|
7
|
+
if len(args) < 1:
|
|
8
|
+
return 0
|
|
9
|
+
|
|
10
|
+
from ring.focus import jump as focus_jump
|
|
11
|
+
from ring.ipc import read_tui_presence, write_focus_request
|
|
12
|
+
from ring.sources import get_by_id
|
|
13
|
+
|
|
14
|
+
session_id = args[0]
|
|
15
|
+
session = get_by_id(session_id)
|
|
16
|
+
if session is None:
|
|
17
|
+
return 0
|
|
18
|
+
presence = read_tui_presence()
|
|
19
|
+
if presence is not None:
|
|
20
|
+
write_focus_request(session_id)
|
|
21
|
+
else:
|
|
22
|
+
focus_jump(session)
|
|
23
|
+
return 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""``ring gc`` command handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from ring.commands._args import strip_lang
|
|
9
|
+
from ring.gc import DEFAULT_OLDER_THAN_SECONDS, parse_duration
|
|
10
|
+
from ring.gc import run_gc as gc_run
|
|
11
|
+
from ring.i18n import gettext as _
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_gc(args: list[str]) -> int:
|
|
15
|
+
"""清理 RiNG 自己的 stale 狀態檔。"""
|
|
16
|
+
args = strip_lang(args)
|
|
17
|
+
|
|
18
|
+
parser = argparse.ArgumentParser(prog="ring gc", description=_("清理 RiNG 自己的 stale 狀態檔。"))
|
|
19
|
+
parser.add_argument("--dry-run", action="store_true", help=_("只預覽,不刪檔"))
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--older-than",
|
|
22
|
+
default="7d",
|
|
23
|
+
metavar="DURATION",
|
|
24
|
+
help=_("清理超過指定時間的已離場 registry(例如 30m、2h、7d;預設 7d)"),
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument("--all-ended", action="store_true", help=_("清理所有目前判定已離場的 registry"))
|
|
27
|
+
try:
|
|
28
|
+
ns = parser.parse_args(args)
|
|
29
|
+
older_than = parse_duration(ns.older_than) if ns.older_than else DEFAULT_OLDER_THAN_SECONDS
|
|
30
|
+
except SystemExit as e:
|
|
31
|
+
return e.code if isinstance(e.code, int) else 2
|
|
32
|
+
except ValueError:
|
|
33
|
+
print(_("無效的 --older-than:{value}", value=ns.older_than), file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
|
|
36
|
+
result = gc_run(older_than=older_than, all_ended=ns.all_ended, dry_run=ns.dry_run)
|
|
37
|
+
action = _("將刪除") if result.dry_run else _("已刪除")
|
|
38
|
+
count = len(result.candidates) if result.dry_run else len(result.deleted)
|
|
39
|
+
print(_("RiNG GC:{action} {count} 個檔案", action=action, count=count))
|
|
40
|
+
shown = result.candidates if result.dry_run else result.deleted
|
|
41
|
+
for candidate in shown:
|
|
42
|
+
prefix = " - " if result.dry_run else " ✓ "
|
|
43
|
+
print(f"{prefix}{candidate.path} ({candidate.reason})")
|
|
44
|
+
for candidate, error in result.errors:
|
|
45
|
+
print(f" ! {candidate.path} ({error})", file=sys.stderr)
|
|
46
|
+
return 1 if result.errors else 0
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Hook-related CLI command handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_hook_command(args: list[str]) -> int:
|
|
7
|
+
from ring.hook import run_hook
|
|
8
|
+
|
|
9
|
+
provider = "claude-code"
|
|
10
|
+
if args:
|
|
11
|
+
if args[0] == "--provider" and len(args) >= 2:
|
|
12
|
+
provider = args[1]
|
|
13
|
+
elif args[0].startswith("--provider="):
|
|
14
|
+
provider = args[0].split("=", 1)[1]
|
|
15
|
+
elif not args[0].startswith("-"):
|
|
16
|
+
provider = args[0]
|
|
17
|
+
return run_hook(provider=provider)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_install_hooks(args: list[str]) -> int:
|
|
21
|
+
from ring.hook import install_hooks
|
|
22
|
+
|
|
23
|
+
return install_hooks(dry_run="--dry-run" in args)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_remove_hooks(args: list[str]) -> int:
|
|
27
|
+
from ring.hook import uninstall_hooks
|
|
28
|
+
|
|
29
|
+
return uninstall_hooks(dry_run="--dry-run" in args)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""RiNG 自有狀態檔清理。
|
|
2
|
+
|
|
3
|
+
只清 RiNG 寫在 ``~/.config/ring/`` 底下的檔案;不碰 Claude Code / Codex 的
|
|
4
|
+
transcript、SQLite state 或任何 provider 原始資料。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ring.ipc import _FOCUS_REQUEST_PATH, _PRESENCE_PATH, _PRESENCE_TTL, _REQUEST_TTL
|
|
15
|
+
from ring.registry import (
|
|
16
|
+
_SESSION_START_SOURCES,
|
|
17
|
+
RING_REGISTRY,
|
|
18
|
+
Status,
|
|
19
|
+
_hook_sessions,
|
|
20
|
+
collect_provider_procs,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_OLDER_THAN_SECONDS = 7 * 24 * 60 * 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class GcCandidate:
|
|
28
|
+
path: Path
|
|
29
|
+
reason: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class GcResult:
|
|
34
|
+
candidates: list[GcCandidate]
|
|
35
|
+
deleted: list[GcCandidate]
|
|
36
|
+
errors: list[tuple[GcCandidate, str]]
|
|
37
|
+
dry_run: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_duration(raw: str) -> float:
|
|
41
|
+
"""解析簡單 duration:``30s``、``10m``、``2h``、``7d``,無單位視為秒。"""
|
|
42
|
+
text = raw.strip().lower()
|
|
43
|
+
if not text:
|
|
44
|
+
raise ValueError("empty duration")
|
|
45
|
+
unit = text[-1]
|
|
46
|
+
if unit in {"s", "m", "h", "d"}:
|
|
47
|
+
number = text[:-1]
|
|
48
|
+
factor = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit]
|
|
49
|
+
else:
|
|
50
|
+
number = text
|
|
51
|
+
factor = 1
|
|
52
|
+
seconds = float(number) * factor
|
|
53
|
+
if seconds < 0:
|
|
54
|
+
raise ValueError("negative duration")
|
|
55
|
+
return seconds
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def collect_candidates(*, older_than: float, all_ended: bool = False, now: float | None = None) -> list[GcCandidate]:
|
|
59
|
+
"""收集可清理的 RiNG 自有狀態檔。"""
|
|
60
|
+
current = time.time() if now is None else now
|
|
61
|
+
candidates: dict[Path, GcCandidate] = {}
|
|
62
|
+
|
|
63
|
+
for candidate in _registry_candidates(older_than=older_than, all_ended=all_ended, now=current):
|
|
64
|
+
candidates[candidate.path] = candidate
|
|
65
|
+
for candidate in _ipc_candidates(now=current):
|
|
66
|
+
candidates[candidate.path] = candidate
|
|
67
|
+
|
|
68
|
+
return sorted(candidates.values(), key=lambda c: str(c.path))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_gc(
|
|
72
|
+
*, older_than: float = DEFAULT_OLDER_THAN_SECONDS, all_ended: bool = False, dry_run: bool = False
|
|
73
|
+
) -> GcResult:
|
|
74
|
+
candidates = collect_candidates(older_than=older_than, all_ended=all_ended)
|
|
75
|
+
deleted: list[GcCandidate] = []
|
|
76
|
+
errors: list[tuple[GcCandidate, str]] = []
|
|
77
|
+
if dry_run:
|
|
78
|
+
return GcResult(candidates=candidates, deleted=deleted, errors=errors, dry_run=True)
|
|
79
|
+
|
|
80
|
+
for candidate in candidates:
|
|
81
|
+
try:
|
|
82
|
+
candidate.path.unlink(missing_ok=True)
|
|
83
|
+
deleted.append(candidate)
|
|
84
|
+
except OSError as e:
|
|
85
|
+
errors.append((candidate, str(e)))
|
|
86
|
+
return GcResult(candidates=candidates, deleted=deleted, errors=errors, dry_run=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _registry_candidates(*, older_than: float, all_ended: bool, now: float) -> list[GcCandidate]:
|
|
90
|
+
if not RING_REGISTRY.is_dir():
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
candidates: list[GcCandidate] = []
|
|
94
|
+
sessions = _hook_sessions(
|
|
95
|
+
procs_by_provider=collect_provider_procs(),
|
|
96
|
+
purge_session_start_phantoms=False,
|
|
97
|
+
)
|
|
98
|
+
ended = {s.session_id: s for s in sessions if s.status is Status.ENDED}
|
|
99
|
+
|
|
100
|
+
for path in RING_REGISTRY.glob("*.json"):
|
|
101
|
+
try:
|
|
102
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
103
|
+
except (OSError, json.JSONDecodeError):
|
|
104
|
+
candidates.append(GcCandidate(path, "registry JSON 無法讀取"))
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
provider = str(data.get("provider", "claude-code") or "claude-code")
|
|
108
|
+
if provider in _SESSION_START_SOURCES:
|
|
109
|
+
candidates.append(GcCandidate(path, "舊版 SessionStart 幽靈檔"))
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
sid = str(data.get("session_id", ""))
|
|
113
|
+
session = ended.get(sid)
|
|
114
|
+
if session is None:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
last_active = _last_active(data)
|
|
118
|
+
if all_ended or now - last_active >= older_than:
|
|
119
|
+
candidates.append(GcCandidate(path, "已離場 registry"))
|
|
120
|
+
|
|
121
|
+
return candidates
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _ipc_candidates(*, now: float) -> list[GcCandidate]:
|
|
125
|
+
candidates: list[GcCandidate] = []
|
|
126
|
+
candidates.extend(_stale_json_file(_FOCUS_REQUEST_PATH, ttl=_REQUEST_TTL, now=now, label="focus-request"))
|
|
127
|
+
candidates.extend(_stale_json_file(_PRESENCE_PATH, ttl=_PRESENCE_TTL, now=now, label="tui-presence"))
|
|
128
|
+
return candidates
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _stale_json_file(path: Path, *, ttl: float, now: float, label: str) -> list[GcCandidate]:
|
|
132
|
+
try:
|
|
133
|
+
raw = path.read_text(encoding="utf-8")
|
|
134
|
+
except OSError:
|
|
135
|
+
return []
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(raw)
|
|
138
|
+
ts = float(data["ts"])
|
|
139
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
140
|
+
return [GcCandidate(path, f"{label} JSON 無法讀取")]
|
|
141
|
+
if now - ts > ttl:
|
|
142
|
+
return [GcCandidate(path, f"{label} 已過期")]
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _last_active(data: object) -> float:
|
|
147
|
+
if not isinstance(data, dict):
|
|
148
|
+
return 0.0
|
|
149
|
+
try:
|
|
150
|
+
return float(data.get("last_active", 0.0))
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
return 0.0
|
|
@@ -33,6 +33,7 @@ from ring.i18n import gettext as _
|
|
|
33
33
|
from ring.i18n import set_lang
|
|
34
34
|
from ring.registry import (
|
|
35
35
|
RING_REGISTRY,
|
|
36
|
+
Session,
|
|
36
37
|
Status,
|
|
37
38
|
_extract_todo,
|
|
38
39
|
_latest_action,
|
|
@@ -127,11 +128,13 @@ def _session_tty(process_names: tuple[str, ...]) -> str:
|
|
|
127
128
|
|
|
128
129
|
|
|
129
130
|
def run_hook(provider: str = "claude-code") -> int:
|
|
130
|
-
"""讀一次 stdin → 寫 RiNG registry
|
|
131
|
+
"""讀一次 stdin → 寫 RiNG registry 狀態(看板)→ 轉等你時就地發通知 →(可選)委派 agent-hooks。
|
|
131
132
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
轉 🔴 等你的事件,會在當下直接發系統通知(見 ``_ring_waiting_now``)——不必開著看板,
|
|
134
|
+
關掉終端也照樣 ring 你。委派只在 ``notify_backend == "agent-hooks"`` 且 PATH 上有
|
|
135
|
+
``agent-hooks`` 時發生:把原始 payload 透傳給 ``agent-hooks callback``,由它同步出 modal、
|
|
136
|
+
收按鈕、把決策寫到 stdout 回給 Claude(這條路下 ``_ring_waiting_now`` 自動短路、不重複發)。
|
|
137
|
+
其餘情況 RiNG 記狀態 + 發通知後 exit 0(你在終端自己回答)。
|
|
135
138
|
"""
|
|
136
139
|
try:
|
|
137
140
|
raw = sys.stdin.read()
|
|
@@ -191,6 +194,40 @@ def _record_session_state(data: dict[str, Any], selected_provider: str) -> None:
|
|
|
191
194
|
tmp.write_text(json.dumps(payload))
|
|
192
195
|
tmp.replace(path) # atomic
|
|
193
196
|
|
|
197
|
+
if event.status is Status.WAITING:
|
|
198
|
+
_ring_waiting_now(event, payload, last_action)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _ring_waiting_now(event: Any, payload: dict[str, Any], last_action: str) -> None:
|
|
202
|
+
"""session 轉 🔴 等你的當下,就地由 hook 發系統通知——不必等 RiNG 看板輪詢。
|
|
203
|
+
|
|
204
|
+
WAITING 只會從 hook 來(scan 模式永不標 WAITING),所以 hook 是第一手知道「等你」
|
|
205
|
+
的地方;在事件當下通知最即時,也不依賴看板有沒有開著(看板沒開時舊版根本不會 ring
|
|
206
|
+
你)。backend=none / agent-hooks 由 notify_waiting → _select_notifier 自動短路
|
|
207
|
+
(agent-hooks 改走 _delegate_to_agent_hooks 的 modal 委派),不會重複發。
|
|
208
|
+
失敗安靜吞掉,絕不擋住 session。
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
from ring.notify import notify_waiting
|
|
212
|
+
|
|
213
|
+
notify_waiting(
|
|
214
|
+
[
|
|
215
|
+
Session(
|
|
216
|
+
session_id=event.session_id,
|
|
217
|
+
cwd=event.cwd,
|
|
218
|
+
status=Status.WAITING,
|
|
219
|
+
last_active=float(payload.get("last_active", time.time())),
|
|
220
|
+
last_action=last_action,
|
|
221
|
+
source="hook",
|
|
222
|
+
tty=payload.get("tty"),
|
|
223
|
+
provider=event.provider,
|
|
224
|
+
origin_cwd=event.cwd,
|
|
225
|
+
)
|
|
226
|
+
]
|
|
227
|
+
)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
194
231
|
|
|
195
232
|
def _delegate_to_agent_hooks(raw: str, selected_provider: str) -> int:
|
|
196
233
|
"""notify_backend=="agent-hooks" 且 agent-hooks 在 PATH → 透傳 payload 給它出決策。
|
|
@@ -590,7 +590,7 @@ def _codex_threads(procs: list[tuple[str, str]]) -> list[Session]:
|
|
|
590
590
|
continue
|
|
591
591
|
updated_ms = int(row["updated_at_ms"] or 0)
|
|
592
592
|
last_active = updated_ms / 1000 if updated_ms else float(row["updated_at"] or 0)
|
|
593
|
-
if now - last_active > ACTIVE_WINDOW_SECONDS and counts.get(cwd, 0) == 0:
|
|
593
|
+
if now - last_active > ACTIVE_WINDOW_SECONDS and counts.get(_real(cwd), 0) == 0:
|
|
594
594
|
continue
|
|
595
595
|
rollout_path = Path(str(row["rollout_path"] or ""))
|
|
596
596
|
records = _tail_records(rollout_path) if rollout_path else []
|
|
@@ -771,6 +771,7 @@ def _hook_sessions(
|
|
|
771
771
|
procs: list[tuple[str, str]] | None = None,
|
|
772
772
|
*,
|
|
773
773
|
procs_by_provider: dict[str, list[tuple[str, str]]] | None = None,
|
|
774
|
+
purge_session_start_phantoms: bool = True,
|
|
774
775
|
) -> list[Session]:
|
|
775
776
|
if not RING_REGISTRY.is_dir():
|
|
776
777
|
return []
|
|
@@ -786,7 +787,8 @@ def _hook_sessions(
|
|
|
786
787
|
if provider in _SESSION_START_SOURCES:
|
|
787
788
|
# 舊版 bug 把 SessionStart 的 source(startup/resume/clear/compact)誤當
|
|
788
789
|
# provider,留下無 tty、跳不過去、又永不離場的幽靈列。清掉這種腐壞檔,自我修復。
|
|
789
|
-
|
|
790
|
+
if purge_session_start_phantoms:
|
|
791
|
+
f.unlink(missing_ok=True)
|
|
790
792
|
continue
|
|
791
793
|
out.append(
|
|
792
794
|
Session(
|
|
@@ -36,7 +36,6 @@ from ring.i18n import gettext as _
|
|
|
36
36
|
from ring.i18n import set_lang
|
|
37
37
|
from ring.ipc import clear_tui_presence, read_focus_request, write_tui_presence
|
|
38
38
|
from ring.labels import get_label, load_labels, set_label
|
|
39
|
-
from ring.notify import notify_waiting
|
|
40
39
|
from ring.registry import Session, Status, running_agent_pids
|
|
41
40
|
from ring.watcher import WaitingAlertScheduler
|
|
42
41
|
|
|
@@ -280,14 +279,10 @@ class RingApp(App[None]):
|
|
|
280
279
|
cur = next((s for s in self._sessions if s.session_id == self._focused_sid), None)
|
|
281
280
|
if cur is None or cur.status is not Status.WAITING:
|
|
282
281
|
self._focused_sid = None
|
|
282
|
+
# 系統通知(toast)改由 ``ring hook`` 在事件當下發出(見 hook._ring_waiting_now);
|
|
283
|
+
# 這裡只留 TUI 自己的 in-app 響鈴 / 訊息列與醒目標記,不重複發系統通知。
|
|
283
284
|
alerts = self._alerts.feed(self._sessions)
|
|
284
285
|
self._ring_on_waiting_alerts(alerts)
|
|
285
|
-
try:
|
|
286
|
-
hint = notify_waiting(alerts)
|
|
287
|
-
if hint:
|
|
288
|
-
self.notify(hint, timeout=10)
|
|
289
|
-
except Exception:
|
|
290
|
-
pass
|
|
291
286
|
self.sub_title = _header(len(self._sessions), len(running_agent_pids()))
|
|
292
287
|
labels = load_labels()
|
|
293
288
|
table.clear()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|