ring-cli 0.2.0__py3-none-any.whl
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/__init__.py +12 -0
- ring/__main__.py +6 -0
- ring/cli.py +685 -0
- ring/config.py +276 -0
- ring/focus/__init__.py +65 -0
- ring/focus/applescript.py +34 -0
- ring/focus/base.py +19 -0
- ring/focus/iterm2.py +28 -0
- ring/focus/terminal.py +25 -0
- ring/focus/tmux.py +38 -0
- ring/hook.py +482 -0
- ring/hook_protocol.py +264 -0
- ring/i18n.py +52 -0
- ring/ipc.py +220 -0
- ring/labels.py +50 -0
- ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- ring/locale/en/LC_MESSAGES/ring.po +395 -0
- ring/locale/ring.pot +484 -0
- ring/notify/__init__.py +120 -0
- ring/notify/base.py +37 -0
- ring/notify/command.py +18 -0
- ring/notify/notify_send.py +30 -0
- ring/notify/osascript_notifier.py +32 -0
- ring/notify/terminal_notifier.py +64 -0
- ring/osascript.py +17 -0
- ring/registry.py +851 -0
- ring/sources/__init__.py +94 -0
- ring/sources/base.py +17 -0
- ring/sources/claude_code.py +23 -0
- ring/sources/codex.py +16 -0
- ring/sources/hook_registry.py +16 -0
- ring/tui.py +360 -0
- ring/watcher.py +107 -0
- ring_cli-0.2.0.dist-info/METADATA +334 -0
- ring_cli-0.2.0.dist-info/RECORD +38 -0
- ring_cli-0.2.0.dist-info/WHEEL +4 -0
- ring_cli-0.2.0.dist-info/entry_points.txt +3 -0
- ring_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
ring/sources/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Session 來源 package——讓 RiNG 不綁死 Claude Code。
|
|
2
|
+
|
|
3
|
+
每個工具是一個 ``SessionSource``:core 不認識任何具體工具,只把各 source 吐出的
|
|
4
|
+
``Session`` 收齊、配 tmux 座標、排序。要支援別的 agent CLI=在這個 package 加一個
|
|
5
|
+
模組、``register_source()`` 註冊,core 零改動(跟 ``focus`` 的 focuser 同一套設計)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ring.registry as registry
|
|
11
|
+
from ring.registry import Session, Status
|
|
12
|
+
from ring.sources.base import SessionSource
|
|
13
|
+
from ring.sources.claude_code import source as _claude_code
|
|
14
|
+
from ring.sources.codex import source as _codex
|
|
15
|
+
from ring.sources.hook_registry import source as _hook_registry
|
|
16
|
+
|
|
17
|
+
# 註冊表(順序=彙整順序)。hook registry 先於 zero-config source,精準事件優先。
|
|
18
|
+
_SOURCES: list[SessionSource] = [_hook_registry, _claude_code, _codex]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_source(source: SessionSource, *, first: bool = False) -> None:
|
|
22
|
+
"""外部擴充入口:註冊一個自訂 session 來源。"""
|
|
23
|
+
if first:
|
|
24
|
+
_SOURCES.insert(0, source)
|
|
25
|
+
else:
|
|
26
|
+
_SOURCES.append(source)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sources() -> list[SessionSource]:
|
|
30
|
+
"""目前已註冊的來源。"""
|
|
31
|
+
return list(_SOURCES)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def discover_sessions() -> list[Session]:
|
|
35
|
+
"""場館點名:彙整所有來源的 session,配 tmux 座標、排序(等你的排最上面)。"""
|
|
36
|
+
merged: dict[str, Session] = {}
|
|
37
|
+
for source in _SOURCES:
|
|
38
|
+
for s in source.discover():
|
|
39
|
+
current = merged.get(s.session_id)
|
|
40
|
+
merged[s.session_id] = s if current is None else _merge_duplicate_session(current, s)
|
|
41
|
+
found = list(merged.values())
|
|
42
|
+
targets = registry._tmux_targets()
|
|
43
|
+
for s in found:
|
|
44
|
+
s.tmux_target = targets.get(s.cwd)
|
|
45
|
+
found.sort(key=lambda s: (s.status.rank, s.idle_for))
|
|
46
|
+
return found
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _merge_duplicate_session(current: Session, candidate: Session) -> Session:
|
|
50
|
+
"""同一 session 來自多個來源時合併。
|
|
51
|
+
|
|
52
|
+
hook registry 通常最精準,所以預設保留先到的 hook row。不過 Claude Code 有些
|
|
53
|
+
action-required 狀態之後未必會送出能清掉 waiting 的 hook;如果 zero-config scan
|
|
54
|
+
已看到同一 session 有更新紀錄且不再是 WAITING,就用 scan 清掉 stale waiting,
|
|
55
|
+
同時保留 hook 拿到的 tty,避免跳轉能力退化。
|
|
56
|
+
"""
|
|
57
|
+
if (
|
|
58
|
+
current.source == "hook"
|
|
59
|
+
and current.status is Status.WAITING
|
|
60
|
+
and candidate.provider == current.provider
|
|
61
|
+
and candidate.status is not Status.WAITING
|
|
62
|
+
and candidate.last_active > current.last_active
|
|
63
|
+
):
|
|
64
|
+
if not candidate.tty:
|
|
65
|
+
candidate.tty = current.tty
|
|
66
|
+
if not candidate.tmux_target:
|
|
67
|
+
candidate.tmux_target = current.tmux_target
|
|
68
|
+
return candidate
|
|
69
|
+
return current
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_by_id(session_id: str) -> Session | None:
|
|
73
|
+
"""現查現給:重跑 discover_sessions() 後 filter 出指定 session_id。
|
|
74
|
+
|
|
75
|
+
每次呼叫都重跑 discover,不快取舊 Session——scan 的 tty 只在「該 cwd 剛好一個
|
|
76
|
+
live claude」才填,舊 Session 的 tty 可能已失效(例如點擊通知時),
|
|
77
|
+
必須重新 discover 才能拿到當下有效的 tty。
|
|
78
|
+
|
|
79
|
+
:param session_id: 要查詢的 session id。
|
|
80
|
+
:returns: 找到時回對應 Session;找不到回 None。
|
|
81
|
+
"""
|
|
82
|
+
for s in discover_sessions():
|
|
83
|
+
if s.session_id == session_id:
|
|
84
|
+
return s
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"SessionSource",
|
|
90
|
+
"discover_sessions",
|
|
91
|
+
"get_by_id",
|
|
92
|
+
"register_source",
|
|
93
|
+
"sources",
|
|
94
|
+
]
|
ring/sources/base.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""SessionSource 協定(跟 ``focus/base``、``notify/base`` 同一套設計)。
|
|
2
|
+
|
|
3
|
+
``Session`` 本身已是工具中立的(session_id / cwd / status / last_action / tty…),
|
|
4
|
+
所以新 source 只要負責「怎麼找到自己的 session、怎麼填這個 model」。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from ring.registry import Session
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionSource(Protocol):
|
|
15
|
+
name: str
|
|
16
|
+
|
|
17
|
+
def discover(self) -> list[Session]: ...
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Claude Code:掃 ``~/.claude/projects`` 的 JSONL。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ring.registry as registry
|
|
6
|
+
from ring.registry import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClaudeCodeSource:
|
|
10
|
+
name = "claude-code"
|
|
11
|
+
|
|
12
|
+
def discover(self) -> list[Session]:
|
|
13
|
+
procs = registry._claude_procs()
|
|
14
|
+
merged: dict[str, Session] = {}
|
|
15
|
+
for s in registry._scan_sessions(procs):
|
|
16
|
+
merged.setdefault(s.session_id, s)
|
|
17
|
+
existing = list(merged.values())
|
|
18
|
+
for s in registry._synthetic_sessions(procs, existing):
|
|
19
|
+
merged.setdefault(s.session_id, s) # 合成列只填無 row 的 cwd
|
|
20
|
+
return list(merged.values())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
source = ClaudeCodeSource()
|
ring/sources/codex.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Codex CLI:讀 ``~/.codex/state_5.sqlite`` threads,並用 live ``codex`` process 配 tty。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ring.registry as registry
|
|
6
|
+
from ring.registry import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CodexSource:
|
|
10
|
+
name = "codex"
|
|
11
|
+
|
|
12
|
+
def discover(self) -> list[Session]:
|
|
13
|
+
return registry._codex_threads(registry._codex_procs())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
source = CodexSource()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""RiNG hook registry:所有 provider 的精準事件來源。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ring.registry as registry
|
|
6
|
+
from ring.registry import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HookRegistrySource:
|
|
10
|
+
name = "hook"
|
|
11
|
+
|
|
12
|
+
def discover(self) -> list[Session]:
|
|
13
|
+
return registry._hook_sessions(procs_by_provider=registry.collect_provider_procs())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
source = HookRegistrySource()
|
ring/tui.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""RiNG 的 Textual live TUI——鍵盤導覽 + tmux / iTerm2 一鍵跳。
|
|
2
|
+
|
|
3
|
+
需要 textual(``pip install 'ring[tui]'``)。沒裝時 CLI 會自動退回 Rich poll。
|
|
4
|
+
|
|
5
|
+
鍵:↑/↓(或 vim 的 j/k、g/G 跳頭尾)選 session、Enter/Space 跳到它所在的終端、
|
|
6
|
+
a 切換是否顯示已離場、r 刷新、q 離場。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import ClassVar
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.binding import Binding, BindingType
|
|
17
|
+
from textual.containers import Vertical
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
from textual.widgets import DataTable, Footer, Header, Input, Static
|
|
20
|
+
|
|
21
|
+
from ring.cli import (
|
|
22
|
+
_LOC_MAX,
|
|
23
|
+
_STATUS_STYLE,
|
|
24
|
+
_header,
|
|
25
|
+
_middle_truncate,
|
|
26
|
+
_rel,
|
|
27
|
+
board,
|
|
28
|
+
labeled_project,
|
|
29
|
+
provider_label,
|
|
30
|
+
show_tool_column,
|
|
31
|
+
status_label,
|
|
32
|
+
)
|
|
33
|
+
from ring.config import get_config
|
|
34
|
+
from ring.focus import jump as focus_jump
|
|
35
|
+
from ring.i18n import gettext as _
|
|
36
|
+
from ring.i18n import set_lang
|
|
37
|
+
from ring.ipc import clear_tui_presence, read_focus_request, write_tui_presence
|
|
38
|
+
from ring.labels import get_label, load_labels, set_label
|
|
39
|
+
from ring.notify import notify_waiting
|
|
40
|
+
from ring.registry import Session, Status, running_agent_pids
|
|
41
|
+
from ring.watcher import WaitingAlertScheduler
|
|
42
|
+
|
|
43
|
+
_ORDER = (Status.WAITING, Status.WORKING, Status.IDLE, Status.ENDED)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Grid(DataTable[Text]):
|
|
47
|
+
"""看板表格。在 DataTable 既有的方向鍵之外,加上 vim 風的 j/k/g/G 導覽。
|
|
48
|
+
|
|
49
|
+
全部 ``show=False``——footer 保持乾淨,這些只是給手習慣 vim 的人的隱藏快捷。
|
|
50
|
+
對應 DataTable 既有 action:j/k=cursor_down/up、g/G=scroll_top/bottom(cursor_type
|
|
51
|
+
為 row 時,這兩個會把游標移到第一/最後一列,正是 vim 的語意)。
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
55
|
+
Binding("j", "cursor_down", show=False),
|
|
56
|
+
Binding("k", "cursor_up", show=False),
|
|
57
|
+
Binding("g", "scroll_top", show=False),
|
|
58
|
+
Binding("G", "scroll_bottom", show=False),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _NameModal(ModalScreen[str | None]):
|
|
63
|
+
"""為選中的 session 命名的小浮層。Enter 存、Esc 取消、清空移除。
|
|
64
|
+
|
|
65
|
+
dismiss 回傳:輸入字串(含空字串=清除標籤)或 ``None``(取消,不動)。
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
DEFAULT_CSS = """
|
|
69
|
+
_NameModal {
|
|
70
|
+
align: center middle;
|
|
71
|
+
}
|
|
72
|
+
_NameModal #name-box {
|
|
73
|
+
width: 60;
|
|
74
|
+
height: auto;
|
|
75
|
+
padding: 1 2;
|
|
76
|
+
background: $panel;
|
|
77
|
+
border: round $accent;
|
|
78
|
+
}
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, project: str, current: str) -> None:
|
|
82
|
+
super().__init__()
|
|
83
|
+
self._project = project
|
|
84
|
+
self._current = current
|
|
85
|
+
|
|
86
|
+
def compose(self) -> ComposeResult:
|
|
87
|
+
with Vertical(id="name-box"):
|
|
88
|
+
yield Static(_("為 {project} 命名(Enter 存、Esc 取消、清空移除)", project=self._project))
|
|
89
|
+
yield Input(value=self._current, placeholder=_("這個 session 在做什麼…"))
|
|
90
|
+
|
|
91
|
+
def on_mount(self) -> None:
|
|
92
|
+
self.query_one(Input).focus()
|
|
93
|
+
|
|
94
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
95
|
+
self.dismiss(event.value)
|
|
96
|
+
|
|
97
|
+
def key_escape(self) -> None:
|
|
98
|
+
self.dismiss(None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RingApp(App[None]):
|
|
102
|
+
"""場館的即時看板。"""
|
|
103
|
+
|
|
104
|
+
# 按鍵說明在 import 時定下——cli 會在 import 本模組前先 set_lang(),所以吃得到 --lang。
|
|
105
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
106
|
+
Binding("q", "quit", _("離場")),
|
|
107
|
+
Binding("r", "refresh_now", _("刷新")),
|
|
108
|
+
Binding("a", "toggle_all", _("含已離場")),
|
|
109
|
+
Binding("n", "name_session", _("命名")),
|
|
110
|
+
Binding("enter", "jump", _("跳過去")),
|
|
111
|
+
Binding("space", "jump", _("跳過去"), show=False),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def __init__(self, lang: str | None = None, interval: float = 2.0, show_all: bool = False) -> None:
|
|
115
|
+
super().__init__()
|
|
116
|
+
if lang is not None:
|
|
117
|
+
set_lang(lang)
|
|
118
|
+
self._interval = interval
|
|
119
|
+
self._show_all = show_all
|
|
120
|
+
self._sessions: list[Session] = []
|
|
121
|
+
# 工具欄是否顯示(啟動時依首批 session 決定:全同一個 provider 就省掉)。
|
|
122
|
+
self._show_tool: bool = True
|
|
123
|
+
# 通知點過來時指向的 session:那一列要持續醒目標記,直到它離開 WAITING(你回應了)或不在場。
|
|
124
|
+
self._focused_sid: str | None = None
|
|
125
|
+
cfg = get_config()
|
|
126
|
+
self._alerts = WaitingAlertScheduler(cfg.notify_repeat_seconds, cfg.notify_repeat_max)
|
|
127
|
+
self.title = "RiNG 🎤"
|
|
128
|
+
# 記下自己的 controlling tty,供 _poll_focus_request activate 視窗用。
|
|
129
|
+
self._own_tty: str = self._detect_own_tty()
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _detect_own_tty() -> str:
|
|
133
|
+
"""取得 controlling terminal 的 tty 路徑,取不到就回空字串。"""
|
|
134
|
+
import sys as _sys
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
if _sys.stdout.isatty():
|
|
138
|
+
return os.ttyname(_sys.stdout.fileno())
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
try:
|
|
142
|
+
fd = os.open("/dev/tty", os.O_RDONLY | os.O_NOCTTY)
|
|
143
|
+
try:
|
|
144
|
+
return os.ttyname(fd)
|
|
145
|
+
finally:
|
|
146
|
+
os.close(fd)
|
|
147
|
+
except Exception:
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
def compose(self) -> ComposeResult:
|
|
151
|
+
yield Header(show_clock=True)
|
|
152
|
+
yield Static(id="legend")
|
|
153
|
+
yield _Grid(id="grid", zebra_stripes=True)
|
|
154
|
+
yield Static(id="status")
|
|
155
|
+
yield Footer()
|
|
156
|
+
|
|
157
|
+
def _setup_columns(self) -> None:
|
|
158
|
+
"""依 self._show_tool 重建 DataTable 欄位。
|
|
159
|
+
|
|
160
|
+
呼叫前須先完成 clear(columns=True)(或首次 mount 還沒有欄位)。
|
|
161
|
+
"""
|
|
162
|
+
table = self.query_one(DataTable)
|
|
163
|
+
cols = [_("狀態")]
|
|
164
|
+
if self._show_tool:
|
|
165
|
+
cols.append(_("工具"))
|
|
166
|
+
cols += [_("專案"), _("進度"), _("閒置"), _("去哪"), _("動作")]
|
|
167
|
+
for label in cols:
|
|
168
|
+
table.add_column(label)
|
|
169
|
+
|
|
170
|
+
def on_mount(self) -> None:
|
|
171
|
+
# 寫入 presence,讓 `ring focus` 知道 TUI 在跑。
|
|
172
|
+
write_tui_presence()
|
|
173
|
+
# 啟動時依首批 session 決定要不要顯示工具欄。
|
|
174
|
+
self._sessions = board(self._show_all)
|
|
175
|
+
self._show_tool = show_tool_column(self._sessions)
|
|
176
|
+
table = self.query_one(DataTable)
|
|
177
|
+
self._setup_columns()
|
|
178
|
+
table.cursor_type = "row"
|
|
179
|
+
table.focus() # 讓 ↑/↓ 與 Enter 直接作用在表格上
|
|
180
|
+
self._render_legend()
|
|
181
|
+
self._reload()
|
|
182
|
+
self.set_interval(self._interval, self._reload)
|
|
183
|
+
if not self._hooks_active() and self._has_cwd_collision():
|
|
184
|
+
self._set_status(_("💡 同專案開了多個 session,裝 hook 跳轉才精準:ring install-hooks"))
|
|
185
|
+
else:
|
|
186
|
+
self._set_status(_("↑/↓ 選一列,Enter 或 Space 跳到那個 session 的終端"))
|
|
187
|
+
|
|
188
|
+
def on_unmount(self) -> None:
|
|
189
|
+
# 清除 presence,TUI 離場後 `ring focus` 退回 headless 行為。
|
|
190
|
+
clear_tui_presence()
|
|
191
|
+
|
|
192
|
+
def _set_status(self, text: str) -> None:
|
|
193
|
+
self.query_one("#status", Static).update(text)
|
|
194
|
+
|
|
195
|
+
def _hooks_active(self) -> bool:
|
|
196
|
+
return any(s.source == "hook" for s in self._sessions)
|
|
197
|
+
|
|
198
|
+
def _has_cwd_collision(self) -> bool:
|
|
199
|
+
"""同一個 cwd 有多個還在場的 session → scan 模式分不出 tty,hook 才精準。"""
|
|
200
|
+
live = [s.cwd for s in self._sessions if s.status is not Status.ENDED]
|
|
201
|
+
return len(live) != len(set(live))
|
|
202
|
+
|
|
203
|
+
def _render_legend(self) -> None:
|
|
204
|
+
legend = Text(f"{_('圖例')} ", style="grey50")
|
|
205
|
+
for status in _ORDER:
|
|
206
|
+
legend.append(f"{status.marker} {status_label(status)} ", style=_STATUS_STYLE[status])
|
|
207
|
+
self.query_one("#legend", Static).update(legend)
|
|
208
|
+
|
|
209
|
+
def _ring_on_waiting_alerts(self, alerts: list[Session]) -> None:
|
|
210
|
+
"""有 session 需要提醒 → RiNG 真的「ring」你(響鈴 + toast 通知)。"""
|
|
211
|
+
if alerts:
|
|
212
|
+
self.bell()
|
|
213
|
+
names = ", ".join(sorted(s.project for s in alerts))
|
|
214
|
+
self.notify(_("🔔 {names} 在等你回話", names=names), timeout=8)
|
|
215
|
+
|
|
216
|
+
def _activate_own_window(self) -> None:
|
|
217
|
+
"""把 RiNG 自己的終端視窗帶到前景(best-effort,失敗安靜吞)。
|
|
218
|
+
|
|
219
|
+
複用既有 focuser 鏈(tmux / iTerm2 / Terminal):用自己的 tty 組一個 self-Session
|
|
220
|
+
丟給 ``focus_jump``,誰接得住誰來——這樣 tmux 與各 macOS 終端都走同一條路,
|
|
221
|
+
不必在這裡手寫 AppleScript。
|
|
222
|
+
"""
|
|
223
|
+
if not self._own_tty:
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
focus_jump(Session("self", "/", Status.WORKING, 0.0, "", "ipc", tty=self._own_tty))
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
def _poll_focus_request(self) -> None:
|
|
231
|
+
"""讀一次 focus-request 檔,有效的話把游標跳過去、持續標記,並 activate 自己視窗。
|
|
232
|
+
|
|
233
|
+
1. 讀 focus-request;無 / 過期 / 解析失敗 → 直接回傳(read_focus_request 已消費即焚)。
|
|
234
|
+
2. activate 自己視窗(複用 focuser 鏈)。
|
|
235
|
+
3. 在 _sessions 找 session_id → 移游標 + 記住 _focused_sid(那列持續標記直到回應)。
|
|
236
|
+
4. 找不到 → 走「已不在場」分支,清掉 _focused_sid。
|
|
237
|
+
"""
|
|
238
|
+
sid = read_focus_request()
|
|
239
|
+
if sid is None:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
self._activate_own_window()
|
|
243
|
+
|
|
244
|
+
target_row: int | None = None
|
|
245
|
+
for idx, s in enumerate(self._sessions):
|
|
246
|
+
if s.session_id == sid:
|
|
247
|
+
target_row = idx
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
if target_row is not None:
|
|
251
|
+
self._focused_sid = sid
|
|
252
|
+
table = self.query_one(DataTable)
|
|
253
|
+
table.move_cursor(row=target_row)
|
|
254
|
+
found_session = self._sessions[target_row]
|
|
255
|
+
msg = _("→ 已跳到 {project}(來自通知)", project=found_session.project)
|
|
256
|
+
self._set_status(msg)
|
|
257
|
+
self.notify(msg, timeout=8)
|
|
258
|
+
else:
|
|
259
|
+
self._focused_sid = None
|
|
260
|
+
msg = _("那個 session 已不在場")
|
|
261
|
+
self._set_status(msg)
|
|
262
|
+
self.notify(msg, severity="warning", timeout=8)
|
|
263
|
+
|
|
264
|
+
def _reload(self) -> None:
|
|
265
|
+
# 每次刷新都續寫 presence,避免 TUI 開超過 TTL 後 `ring focus` 誤判 TUI 沒在跑、
|
|
266
|
+
# 退回去跳 session 自己的終端(scan 模式常沒 tty → 跳轉失敗)。
|
|
267
|
+
write_tui_presence()
|
|
268
|
+
self._sessions = board(self._show_all)
|
|
269
|
+
table = self.query_one(DataTable)
|
|
270
|
+
# cursor 快照要在「任何」clear 之前取:clear(columns=True) 會把 cursor_row reset 成 0。
|
|
271
|
+
cursor = table.cursor_row
|
|
272
|
+
# 動態更新工具欄:名單變了(混用 ↔ 全同一種),重建欄位;沒變就省掉欄位重建。
|
|
273
|
+
new_show_tool = show_tool_column(self._sessions)
|
|
274
|
+
if new_show_tool != self._show_tool:
|
|
275
|
+
self._show_tool = new_show_tool
|
|
276
|
+
table.clear(columns=True) # columns=True 同時清欄位與資料列
|
|
277
|
+
self._setup_columns()
|
|
278
|
+
# 通知指向的 session 一旦離開 WAITING(你回應了)或不在場,就解除醒目標記。
|
|
279
|
+
if self._focused_sid is not None:
|
|
280
|
+
cur = next((s for s in self._sessions if s.session_id == self._focused_sid), None)
|
|
281
|
+
if cur is None or cur.status is not Status.WAITING:
|
|
282
|
+
self._focused_sid = None
|
|
283
|
+
alerts = self._alerts.feed(self._sessions)
|
|
284
|
+
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
|
+
self.sub_title = _header(len(self._sessions), len(running_agent_pids()))
|
|
292
|
+
labels = load_labels()
|
|
293
|
+
table.clear()
|
|
294
|
+
for s in self._sessions:
|
|
295
|
+
focused = s.session_id == self._focused_sid
|
|
296
|
+
marker = "👉 " if focused else ""
|
|
297
|
+
style = f"reverse {_STATUS_STYLE[s.status]}" if focused else _STATUS_STYLE[s.status]
|
|
298
|
+
status_cell = Text(f"{marker}{s.status.marker} {status_label(s.status)}", style=style)
|
|
299
|
+
progress = f"{s.todo[0]}/{s.todo[1]}" if s.todo else "·"
|
|
300
|
+
loc_cell = f"📍{_middle_truncate(s.location, _LOC_MAX)}"
|
|
301
|
+
project_cell = labeled_project(s.project, labels.get(s.session_id, ""))
|
|
302
|
+
cells: list[object] = [status_cell]
|
|
303
|
+
if self._show_tool:
|
|
304
|
+
cells.append(provider_label(s.provider))
|
|
305
|
+
cells += [project_cell, progress, _rel(s.idle_for), loc_cell, s.last_action]
|
|
306
|
+
table.add_row(*cells)
|
|
307
|
+
if self._sessions:
|
|
308
|
+
table.move_cursor(row=min(cursor, len(self._sessions) - 1))
|
|
309
|
+
self._poll_focus_request()
|
|
310
|
+
|
|
311
|
+
def _selected(self) -> Session | None:
|
|
312
|
+
row = self.query_one(DataTable).cursor_row
|
|
313
|
+
if 0 <= row < len(self._sessions):
|
|
314
|
+
return self._sessions[row]
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
318
|
+
# DataTable 被 focus 時會吃掉 Enter(變成 RowSelected),在這裡接、轉成跳轉。
|
|
319
|
+
self.action_jump()
|
|
320
|
+
|
|
321
|
+
def action_name_session(self) -> None:
|
|
322
|
+
s = self._selected()
|
|
323
|
+
if s is None:
|
|
324
|
+
self._set_status(_("(沒有選到 session)"))
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
def _save(label: str | None) -> None:
|
|
328
|
+
if label is None:
|
|
329
|
+
return # Esc 取消,不動
|
|
330
|
+
set_label(s.session_id, label)
|
|
331
|
+
self._reload()
|
|
332
|
+
|
|
333
|
+
self.push_screen(_NameModal(s.project, get_label(s.session_id)), _save)
|
|
334
|
+
|
|
335
|
+
def action_refresh_now(self) -> None:
|
|
336
|
+
self._reload()
|
|
337
|
+
|
|
338
|
+
def action_toggle_all(self) -> None:
|
|
339
|
+
self._show_all = not self._show_all
|
|
340
|
+
self._reload()
|
|
341
|
+
|
|
342
|
+
def action_jump(self) -> None:
|
|
343
|
+
s = self._selected()
|
|
344
|
+
if s is None:
|
|
345
|
+
self._set_status(_("(沒有選到 session)"))
|
|
346
|
+
return
|
|
347
|
+
if s.session_id == self._focused_sid:
|
|
348
|
+
self._focused_sid = None # 你已親自跳過去處理,解除通知標記
|
|
349
|
+
ok, msg = focus_jump(s)
|
|
350
|
+
if ok:
|
|
351
|
+
text = _("→ {project}({where})", project=s.project, where=msg)
|
|
352
|
+
else:
|
|
353
|
+
text = _("{project}:{msg}", project=s.project, msg=msg)
|
|
354
|
+
self._set_status(text)
|
|
355
|
+
self.notify(text, severity="information" if ok else "warning", timeout=10)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def run_tui(interval: float = 2.0, show_all: bool = False) -> int:
|
|
359
|
+
RingApp(interval=interval, show_all=show_all).run()
|
|
360
|
+
return 0
|
ring/watcher.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""不依賴 Textual 的「新轉 waiting 偵測器」狀態件。
|
|
2
|
+
|
|
3
|
+
讓 TUI 與 headless watch() 兩條 loop 共用同一套「某 session 新轉為 waiting」邏輯,
|
|
4
|
+
避免維護兩套可能打架的差異邏輯。完全不 import Textual,可在無 macOS GUI 環境單測。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ring.registry import Session, Status
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WaitingWatcher:
|
|
17
|
+
"""追蹤 session snapshot 差集,回傳「本輪新轉為 waiting」的清單。
|
|
18
|
+
|
|
19
|
+
第一輪(prime 語意):只記錄當前 waiting set、回空清單,避免啟動瞬間把所有既有
|
|
20
|
+
waiting session 當「新轉」狂發通知。第二輪起才開始吐差集。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._waiting_ids: set[str] = set()
|
|
25
|
+
self._primed: bool = False
|
|
26
|
+
|
|
27
|
+
def feed(self, sessions: list[Session]) -> list[Session]:
|
|
28
|
+
"""餵入當前 snapshot,回傳「本輪新轉為 waiting」的 Session 清單。
|
|
29
|
+
|
|
30
|
+
:param sessions: 當前完整 session 快照(通常來自 board() / discover_sessions())。
|
|
31
|
+
:returns: 本輪從「非 waiting」轉為「waiting」的 session;第一輪一律回空清單。
|
|
32
|
+
"""
|
|
33
|
+
current_waiting = {s.session_id for s in sessions if s.status is Status.WAITING}
|
|
34
|
+
if not self._primed:
|
|
35
|
+
# 第一輪只記錄,不發通知
|
|
36
|
+
self._waiting_ids = current_waiting
|
|
37
|
+
self._primed = True
|
|
38
|
+
return []
|
|
39
|
+
newly = current_waiting - self._waiting_ids
|
|
40
|
+
self._waiting_ids = current_waiting
|
|
41
|
+
if not newly:
|
|
42
|
+
return []
|
|
43
|
+
return [s for s in sessions if s.session_id in newly]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class _AlertState:
|
|
48
|
+
first_seen: float
|
|
49
|
+
last_alert: float
|
|
50
|
+
repeats_sent: int = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class WaitingAlertScheduler:
|
|
54
|
+
"""決定哪些 waiting session 此輪需要通知。
|
|
55
|
+
|
|
56
|
+
第一輪只 prime,不通知既有 waiting;之後新轉 waiting 立即通知,持續 waiting 則依
|
|
57
|
+
``repeat_seconds`` 做最多 ``repeat_max`` 次提醒(0 = 不限)。
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
repeat_seconds: tuple[int, ...] = (),
|
|
63
|
+
repeat_max: int = 0,
|
|
64
|
+
*,
|
|
65
|
+
now: Callable[[], float] = time.time,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._repeat_seconds = repeat_seconds
|
|
68
|
+
self._repeat_max = max(0, repeat_max)
|
|
69
|
+
self._now = now
|
|
70
|
+
self._states: dict[str, _AlertState] = {}
|
|
71
|
+
self._primed = False
|
|
72
|
+
|
|
73
|
+
def feed(self, sessions: list[Session]) -> list[Session]:
|
|
74
|
+
current = {s.session_id: s for s in sessions if s.status is Status.WAITING}
|
|
75
|
+
now = self._now()
|
|
76
|
+
|
|
77
|
+
if not self._primed:
|
|
78
|
+
self._states = {sid: _AlertState(first_seen=now, last_alert=now) for sid in current}
|
|
79
|
+
self._primed = True
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
due: list[Session] = []
|
|
83
|
+
next_states: dict[str, _AlertState] = {}
|
|
84
|
+
for sid, session in current.items():
|
|
85
|
+
state = self._states.get(sid)
|
|
86
|
+
if state is None:
|
|
87
|
+
next_states[sid] = _AlertState(first_seen=now, last_alert=now)
|
|
88
|
+
due.append(session)
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
if self._should_repeat(state, now):
|
|
92
|
+
state.last_alert = now
|
|
93
|
+
state.repeats_sent += 1
|
|
94
|
+
due.append(session)
|
|
95
|
+
next_states[sid] = state
|
|
96
|
+
|
|
97
|
+
self._states = next_states
|
|
98
|
+
return due
|
|
99
|
+
|
|
100
|
+
def _should_repeat(self, state: _AlertState, now: float) -> bool:
|
|
101
|
+
if not self._repeat_seconds:
|
|
102
|
+
return False
|
|
103
|
+
if self._repeat_max and state.repeats_sent >= self._repeat_max:
|
|
104
|
+
return False
|
|
105
|
+
if state.repeats_sent < len(self._repeat_seconds):
|
|
106
|
+
return now - state.first_seen >= self._repeat_seconds[state.repeats_sent]
|
|
107
|
+
return now - state.last_alert >= self._repeat_seconds[-1]
|