ring-cli 0.14.0__tar.gz → 0.14.2__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.14.0 → ring_cli-0.14.2}/PKG-INFO +1 -1
- ring_cli-0.14.2/pyproject.toml +212 -0
- ring_cli-0.14.0/pyproject.toml → ring_cli-0.14.2/pyproject.toml.orig +1 -1
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/focus/__init__.py +29 -8
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/focus/applescript.py +8 -0
- ring_cli-0.14.2/src/ring/focus/iterm2.py +82 -0
- ring_cli-0.14.2/src/ring/focus/trace.py +79 -0
- ring_cli-0.14.2/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/locale/en/LC_MESSAGES/ring.po +3 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/locale/ring.pot +9 -4
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/registry.py +38 -0
- ring_cli-0.14.0/src/ring/focus/iterm2.py +0 -28
- ring_cli-0.14.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/LICENSE +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/README.en.md +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/__init__.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/__main__.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/cli.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/quiet.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/config.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/focus/base.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/gc.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/hook.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/i18n.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/ipc.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/labels.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/base.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/command.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/notify_queue.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/osascript.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/payload_log.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/permission.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/plugins.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/question_detect.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/sources/__init__.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/sources/base.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/sources/local_llm.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/stats.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/transcript.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/tui.py +0 -0
- {ring_cli-0.14.0 → ring_cli-0.14.2}/src/ring/watcher.py +0 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ring-cli"
|
|
3
|
+
version = "0.14.2"
|
|
4
|
+
description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
|
|
5
|
+
readme = "README.en.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"claude-code",
|
|
11
|
+
"codex",
|
|
12
|
+
"ollama",
|
|
13
|
+
"llama-cpp",
|
|
14
|
+
"tui",
|
|
15
|
+
"dashboard",
|
|
16
|
+
"session",
|
|
17
|
+
"monitor",
|
|
18
|
+
"rich",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Operating System :: MacOS",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Topic :: Software Development",
|
|
28
|
+
"Topic :: Utilities",
|
|
29
|
+
]
|
|
30
|
+
dependencies = ["rich>=13"]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "Wei Lee"
|
|
34
|
+
email = "weilee.rx@gmail.com"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/Lee-W/ring"
|
|
38
|
+
Repository = "https://github.com/Lee-W/ring"
|
|
39
|
+
Documentation = "https://lee-w.github.io/ring/"
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
tui = ["textual>=0.80"]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
ring = "ring.cli:main"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
base = [
|
|
49
|
+
"poethepoet>=0.41.0",
|
|
50
|
+
"babel>=2.14",
|
|
51
|
+
]
|
|
52
|
+
test = [
|
|
53
|
+
"pytest>=9",
|
|
54
|
+
"pytest-cov>=4",
|
|
55
|
+
"pytest-xdist>=3.1.0",
|
|
56
|
+
"pytest-asyncio>=0.24",
|
|
57
|
+
"textual>=0.80",
|
|
58
|
+
]
|
|
59
|
+
linters = [
|
|
60
|
+
"ruff>=0.15.1",
|
|
61
|
+
"mypy>=1.19.1",
|
|
62
|
+
"prek>=0.3.3",
|
|
63
|
+
"commitizen>=4.13.9",
|
|
64
|
+
]
|
|
65
|
+
docs = [
|
|
66
|
+
"mkdocs-material>=9,<10",
|
|
67
|
+
"mkdocs-static-i18n>=1.3,<2",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[[dependency-groups.dev]]
|
|
71
|
+
include-group = "base"
|
|
72
|
+
|
|
73
|
+
[[dependency-groups.dev]]
|
|
74
|
+
include-group = "test"
|
|
75
|
+
|
|
76
|
+
[[dependency-groups.dev]]
|
|
77
|
+
include-group = "linters"
|
|
78
|
+
|
|
79
|
+
[build-system]
|
|
80
|
+
requires = ["uv_build >= 0.10.3, <0.12.0"]
|
|
81
|
+
build-backend = "uv_build"
|
|
82
|
+
|
|
83
|
+
[tool.uv.build-backend]
|
|
84
|
+
module-name = "ring"
|
|
85
|
+
module-root = "src"
|
|
86
|
+
|
|
87
|
+
[tool.mypy]
|
|
88
|
+
files = [
|
|
89
|
+
"src",
|
|
90
|
+
"tests",
|
|
91
|
+
]
|
|
92
|
+
strict = true
|
|
93
|
+
warn_redundant_casts = true
|
|
94
|
+
warn_unused_ignores = true
|
|
95
|
+
warn_unused_configs = true
|
|
96
|
+
|
|
97
|
+
[tool.ruff]
|
|
98
|
+
target-version = "py313"
|
|
99
|
+
line-length = 120
|
|
100
|
+
|
|
101
|
+
[tool.ruff.lint]
|
|
102
|
+
select = [
|
|
103
|
+
"E",
|
|
104
|
+
"F",
|
|
105
|
+
"W",
|
|
106
|
+
"I",
|
|
107
|
+
"UP",
|
|
108
|
+
"RUF",
|
|
109
|
+
]
|
|
110
|
+
ignore = [
|
|
111
|
+
"RUF001",
|
|
112
|
+
"RUF002",
|
|
113
|
+
"RUF003",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
[tool.codespell]
|
|
117
|
+
skip = ".git*,*.lock"
|
|
118
|
+
check-hidden = true
|
|
119
|
+
ignore-words-list = "otification"
|
|
120
|
+
|
|
121
|
+
[tool.poe.tasks.format]
|
|
122
|
+
help = "Format the code"
|
|
123
|
+
|
|
124
|
+
[[tool.poe.tasks.format.sequence]]
|
|
125
|
+
cmd = "ruff check --fix"
|
|
126
|
+
|
|
127
|
+
[[tool.poe.tasks.format.sequence]]
|
|
128
|
+
cmd = "ruff format"
|
|
129
|
+
|
|
130
|
+
[tool.poe.tasks.lint]
|
|
131
|
+
help = "Lint the code"
|
|
132
|
+
|
|
133
|
+
[[tool.poe.tasks.lint.sequence]]
|
|
134
|
+
cmd = "ruff check"
|
|
135
|
+
|
|
136
|
+
[[tool.poe.tasks.lint.sequence]]
|
|
137
|
+
cmd = "mypy"
|
|
138
|
+
|
|
139
|
+
[tool.poe.tasks.test]
|
|
140
|
+
help = "Run the test suite"
|
|
141
|
+
cmd = "pytest"
|
|
142
|
+
|
|
143
|
+
[tool.poe.tasks.cover]
|
|
144
|
+
help = "Run the test suite with coverage"
|
|
145
|
+
cmd = "pytest --cov=ring --cov-report=term-missing --cov-report=xml:coverage.xml --junitxml=junit.xml -o junit_family=legacy"
|
|
146
|
+
|
|
147
|
+
[tool.poe.tasks.all]
|
|
148
|
+
help = "Run all tasks"
|
|
149
|
+
sequence = [
|
|
150
|
+
"format",
|
|
151
|
+
"lint",
|
|
152
|
+
"cover",
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
[tool.poe.tasks.ci]
|
|
156
|
+
help = "Run all tasks in CI"
|
|
157
|
+
sequence = [
|
|
158
|
+
{ cmd = "prek run --all-files" },
|
|
159
|
+
"cover",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
[tool.poe.tasks.ci.env]
|
|
163
|
+
SKIP = "no-commit-to-branch"
|
|
164
|
+
|
|
165
|
+
[tool.poe.tasks.setup-pre-commit]
|
|
166
|
+
help = "Install pre-commit hooks"
|
|
167
|
+
cmd = "prek install"
|
|
168
|
+
|
|
169
|
+
[tool.poe.tasks."i18n:extract"]
|
|
170
|
+
help = "從原始碼抽出可翻譯字串到 .pot(改字串後重抽。不要用 pybabel update:會塞空 msgstr 進 .po,弄壞 i18n 測試。新字串請手動把已翻譯條目加進各 .po)"
|
|
171
|
+
cmd = "pybabel extract -F babel.cfg -o src/ring/locale/ring.pot src/ring"
|
|
172
|
+
|
|
173
|
+
[tool.poe.tasks."i18n:check"]
|
|
174
|
+
help = "列出原始碼有、但某語言 .po 還沒翻譯的字串(純資訊,不失敗)。先跑 i18n:extract"
|
|
175
|
+
cmd = "python scripts/i18n_check.py"
|
|
176
|
+
|
|
177
|
+
[tool.poe.tasks."i18n:compile"]
|
|
178
|
+
help = "把各語言的 .po 編成 .mo(改 .po 後要重編並 commit)"
|
|
179
|
+
cmd = "pybabel compile -d src/ring/locale -D ring"
|
|
180
|
+
|
|
181
|
+
[tool.poe.tasks."docs:serve"]
|
|
182
|
+
help = "Serve the docs site locally with live reload"
|
|
183
|
+
cmd = "mkdocs serve"
|
|
184
|
+
|
|
185
|
+
[tool.poe.tasks."docs:build"]
|
|
186
|
+
help = "Build the docs site into site/"
|
|
187
|
+
cmd = "mkdocs build --strict"
|
|
188
|
+
|
|
189
|
+
[tool.commitizen]
|
|
190
|
+
name = "cz_conventional_commits"
|
|
191
|
+
tag_format = "$version"
|
|
192
|
+
version_scheme = "pep440"
|
|
193
|
+
version_provider = "uv"
|
|
194
|
+
update_changelog_on_bump = true
|
|
195
|
+
major_version_zero = true
|
|
196
|
+
|
|
197
|
+
[tool.pytest]
|
|
198
|
+
testpaths = ["tests"]
|
|
199
|
+
addopts = [
|
|
200
|
+
"-n",
|
|
201
|
+
"auto",
|
|
202
|
+
"--dist=loadfile",
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
[tool.coverage.run]
|
|
206
|
+
source = ["ring"]
|
|
207
|
+
|
|
208
|
+
[tool.coverage.paths]
|
|
209
|
+
source = [
|
|
210
|
+
"src/ring",
|
|
211
|
+
"*/ring",
|
|
212
|
+
]
|
|
@@ -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.14.
|
|
5
|
+
version = "0.14.2"
|
|
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"
|
|
@@ -15,6 +15,7 @@ from ring.focus.linux_wm import focuser as _linux_wm
|
|
|
15
15
|
from ring.focus.neovim import focuser as _neovim
|
|
16
16
|
from ring.focus.terminal import focuser as _terminal
|
|
17
17
|
from ring.focus.tmux import focuser as _tmux
|
|
18
|
+
from ring.focus.trace import log_jump
|
|
18
19
|
from ring.i18n import gettext as _
|
|
19
20
|
from ring.registry import Session, Status
|
|
20
21
|
|
|
@@ -53,33 +54,53 @@ def focusers() -> list[Focuser]:
|
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
def jump(session: Session) -> tuple[bool, str]:
|
|
56
|
-
"""依序問每個 focuser,誰先接手就用誰。回傳 (成功?, 訊息)。
|
|
57
|
+
"""依序問每個 focuser,誰先接手就用誰。回傳 (成功?, 訊息)。
|
|
58
|
+
|
|
59
|
+
每次跳轉(不論成敗)都記一行到 focus.jsonl,供事後歸因間歇性的跳不過去(見 ``trace``)。
|
|
60
|
+
"""
|
|
57
61
|
if session.status is Status.ENDED:
|
|
58
62
|
return False, _("已離場的 session 無法跳轉")
|
|
59
63
|
failures: list[str] = []
|
|
60
64
|
preparations: list[str] = []
|
|
65
|
+
attempts: list[tuple[str, str]] = []
|
|
66
|
+
|
|
67
|
+
def _done(ok: bool, msg: str) -> tuple[bool, str]:
|
|
68
|
+
log_jump(
|
|
69
|
+
session_id=session.session_id,
|
|
70
|
+
provider=session.provider,
|
|
71
|
+
tty=session.tty or "",
|
|
72
|
+
tmux_target=session.tmux_target or "",
|
|
73
|
+
attempts=attempts,
|
|
74
|
+
ok=ok,
|
|
75
|
+
msg=msg,
|
|
76
|
+
)
|
|
77
|
+
return ok, msg
|
|
78
|
+
|
|
61
79
|
for focuser in _FOCUSERS:
|
|
62
80
|
result = focuser.try_focus(session)
|
|
63
81
|
if result is None:
|
|
82
|
+
attempts.append((focuser.name, "skip"))
|
|
64
83
|
continue
|
|
65
84
|
ok, msg = result
|
|
66
85
|
if ok:
|
|
86
|
+
attempts.append((focuser.name, "ok"))
|
|
67
87
|
if getattr(focuser, "continue_after_success", False):
|
|
68
88
|
preparations.append(msg)
|
|
69
89
|
continue
|
|
70
90
|
if preparations:
|
|
71
|
-
return True, "; ".join([*preparations, msg])
|
|
72
|
-
return True, msg
|
|
91
|
+
return _done(True, "; ".join([*preparations, msg]))
|
|
92
|
+
return _done(True, msg)
|
|
93
|
+
attempts.append((focuser.name, f"fail: {msg}"))
|
|
73
94
|
failures.append(f"{focuser.name}: {msg}")
|
|
74
95
|
if preparations and failures:
|
|
75
|
-
return False, "; ".join([*preparations, *failures])
|
|
96
|
+
return _done(False, "; ".join([*preparations, *failures]))
|
|
76
97
|
if preparations:
|
|
77
|
-
return True, "; ".join(preparations)
|
|
98
|
+
return _done(True, "; ".join(preparations))
|
|
78
99
|
if failures:
|
|
79
|
-
return False, "; ".join(failures)
|
|
100
|
+
return _done(False, "; ".join(failures))
|
|
80
101
|
if session.tty:
|
|
81
|
-
return False, _("找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)")
|
|
82
|
-
return False, _("沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)")
|
|
102
|
+
return _done(False, _("找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)"))
|
|
103
|
+
return _done(False, _("沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)"))
|
|
83
104
|
|
|
84
105
|
|
|
85
106
|
__all__ = ["Focuser", "focusers", "jump", "register_focuser"]
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import shutil
|
|
10
10
|
|
|
11
|
+
from ring.i18n import gettext as _
|
|
11
12
|
from ring.osascript import osascript
|
|
12
13
|
from ring.registry import Session
|
|
13
14
|
|
|
@@ -16,6 +17,9 @@ class AppleScriptTTYFocuser:
|
|
|
16
17
|
"""用 session 的 tty 在某個 macOS 終端 app 裡找到對應分頁並聚焦。
|
|
17
18
|
|
|
18
19
|
各 app 提供自己的 AppleScript(用 ``is running`` 守衛,沒在跑的 app 不會被喚醒)。
|
|
20
|
+
|
|
21
|
+
script 的回傳值三選一:``ok``(找到且已在前景)、``notfound``(不歸這個 app 管)、
|
|
22
|
+
``unraised``(找到分頁但視窗沒浮到前景——腳本自己驗過才回這個,見 iterm2 模組)。
|
|
19
23
|
"""
|
|
20
24
|
|
|
21
25
|
def __init__(self, app: str, script: str) -> None:
|
|
@@ -31,4 +35,8 @@ class AppleScriptTTYFocuser:
|
|
|
31
35
|
return True, f"{self.name} {tty}"
|
|
32
36
|
if rc == 0 and out == "notfound":
|
|
33
37
|
return None # 這個 app 沒有這個 tty → 換下一個 focuser
|
|
38
|
+
if rc == 0 and out == "unraised":
|
|
39
|
+
# 分頁在,但視窗沒被帶到前景(多半在別的 Space 或全螢幕)。照實回失敗,
|
|
40
|
+
# 不要讓 TUI 顯示「跳過去了」卻什麼都沒發生。
|
|
41
|
+
return False, _("{app} 找到分頁了,但視窗沒浮到前景(可能在別的 Space 或全螢幕)", app=self.name)
|
|
34
42
|
return False, err or out or f"returncode={rc}"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""iTerm2 focuser(macOS):用 tty 找到對應分頁並帶到前景。
|
|
2
|
+
|
|
3
|
+
跨視窗(不是同一視窗換分頁)跳轉踩過三個坑,腳本的形狀都是為了繞開它們(皆 2026-07-25 實測):
|
|
4
|
+
|
|
5
|
+
1. **位置型 specifier 會在迴圈中途失效**。``repeat with w in windows`` 的迴圈變數其實是
|
|
6
|
+
``item M of every window`` 這種「按位置」的參照;一旦 windows 的順序在掃描途中變動
|
|
7
|
+
(剛跳過一次視窗、使用者手動點了別的視窗都會),下一次取值就噴
|
|
8
|
+
``Invalid index (-1719)``,跳轉整個失敗——這正是「不同視窗有時抓不到」的主因。
|
|
9
|
+
對策:掃描包在 ``try`` 裡、順序被動到就重掃;``select`` 之後不再用掃描時的位置,
|
|
10
|
+
改以 ``current window`` 為根重找一次分頁。
|
|
11
|
+
2. **``select`` 是非同步的**:送出後立刻讀狀態會讀到舊值。所以最後隔 0.1 秒回頭驗
|
|
12
|
+
``current session of current window``,最多等 1 秒。驗不到就回 ``unraised``,讓上層
|
|
13
|
+
照實說「沒帶到前景」(例如視窗在別的 Space),而不是假裝跳成功。
|
|
14
|
+
3. **不能用的兩招**:``set frontmost of w to true``(Terminal.app 那招)在 iTerm2 噴
|
|
15
|
+
AppleEvent handler failed (-10000);``set index of w to 1`` 會主動重排 windows,
|
|
16
|
+
等於自己製造第 1 點的 -1719。
|
|
17
|
+
|
|
18
|
+
縮到 Dock 的視窗 ``select`` 就叫得回來(實測),``miniaturized`` 那段只是保險。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ring.focus.applescript import AppleScriptTTYFocuser
|
|
24
|
+
|
|
25
|
+
_SCRIPT = """
|
|
26
|
+
if application "iTerm2" is running then
|
|
27
|
+
tell application "iTerm2"
|
|
28
|
+
repeat 3 times
|
|
29
|
+
try
|
|
30
|
+
set wIdx to 0
|
|
31
|
+
set tIdx to 0
|
|
32
|
+
set sIdx to 0
|
|
33
|
+
repeat with wi from 1 to (count of windows)
|
|
34
|
+
repeat with ti from 1 to (count of tabs of window wi)
|
|
35
|
+
repeat with si from 1 to (count of sessions of tab ti of window wi)
|
|
36
|
+
if tty of session si of tab ti of window wi is "{tty}" then
|
|
37
|
+
set wIdx to wi
|
|
38
|
+
set tIdx to ti
|
|
39
|
+
set sIdx to si
|
|
40
|
+
exit repeat
|
|
41
|
+
end if
|
|
42
|
+
end repeat
|
|
43
|
+
if wIdx is not 0 then exit repeat
|
|
44
|
+
end repeat
|
|
45
|
+
if wIdx is not 0 then exit repeat
|
|
46
|
+
end repeat
|
|
47
|
+
if wIdx is 0 then return "notfound"
|
|
48
|
+
activate
|
|
49
|
+
try
|
|
50
|
+
if miniaturized of window wIdx then set miniaturized of window wIdx to false
|
|
51
|
+
end try
|
|
52
|
+
select window wIdx
|
|
53
|
+
tell current window
|
|
54
|
+
repeat with ti2 from 1 to (count of tabs)
|
|
55
|
+
repeat with si2 from 1 to (count of sessions of tab ti2)
|
|
56
|
+
if tty of session si2 of tab ti2 is "{tty}" then
|
|
57
|
+
select tab ti2
|
|
58
|
+
select session si2 of tab ti2
|
|
59
|
+
exit repeat
|
|
60
|
+
end if
|
|
61
|
+
end repeat
|
|
62
|
+
end repeat
|
|
63
|
+
end tell
|
|
64
|
+
repeat 10 times
|
|
65
|
+
delay 0.1
|
|
66
|
+
try
|
|
67
|
+
if tty of current session of current window is "{tty}" then return "ok"
|
|
68
|
+
end try
|
|
69
|
+
end repeat
|
|
70
|
+
return "unraised"
|
|
71
|
+
on error number errNum
|
|
72
|
+
if errNum is -128 then error number -128
|
|
73
|
+
delay 0.2
|
|
74
|
+
end try
|
|
75
|
+
end repeat
|
|
76
|
+
return "unraised"
|
|
77
|
+
end tell
|
|
78
|
+
end if
|
|
79
|
+
return "notfound"
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
focuser = AppleScriptTTYFocuser("iTerm2", _SCRIPT)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""跳轉取證 log——每次 ``jump()`` append 一行到 ``~/.config/ring/focus.jsonl``。
|
|
2
|
+
|
|
3
|
+
為什麼要記:跳轉失敗是間歇性的,而 TUI 的 toast 看過就沒了,事後只剩「昨天有一次跳不
|
|
4
|
+
過去」這種無法歸因的敘述。這份 log 把每個 focuser 當下的回覆存下來,讓下一次重現時能
|
|
5
|
+
直接分辨是哪一種失敗:
|
|
6
|
+
|
|
7
|
+
- 每個 focuser 都回 ``skip``、而 session 有 tty → tty 已失效(分頁關了/registry 記的是舊的)
|
|
8
|
+
- ``tty`` 是空的 → 根本沒抓到 tty,focuser 沒得比對
|
|
9
|
+
- iTerm2 回 ``unraised`` → 分頁找到了但視窗沒浮上來(多半在別的 Space)
|
|
10
|
+
- iTerm2 回 osascript 錯誤 → 權限或 5 秒 timeout
|
|
11
|
+
|
|
12
|
+
跳轉是使用者手動觸發、頻率低(一行約 200 bytes),所以預設就開著、不做開關——要能在
|
|
13
|
+
「下次發生」時就有證據,而不是先想起來去開 debug 旗標。寫入失敗一律安靜吞掉。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
FOCUS_LOG_PATH = Path.home() / ".config" / "ring" / "focus.jsonl"
|
|
23
|
+
|
|
24
|
+
# 一行約 200 bytes,1MB ≈ 5000 次跳轉。超過就砍半保新。
|
|
25
|
+
_MAX_BYTES = 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log_jump(
|
|
29
|
+
*,
|
|
30
|
+
session_id: str,
|
|
31
|
+
provider: str,
|
|
32
|
+
tty: str,
|
|
33
|
+
tmux_target: str,
|
|
34
|
+
attempts: list[tuple[str, str]],
|
|
35
|
+
ok: bool,
|
|
36
|
+
msg: str,
|
|
37
|
+
path: Path | None = None,
|
|
38
|
+
now: float | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""append 一次跳轉的完整經過;失敗安靜吞掉(呼叫端不需要自己包 try)。
|
|
41
|
+
|
|
42
|
+
:param attempts: ``[(focuser 名稱, 結果), …]``,結果為 ``skip`` / ``ok`` / ``fail: …``。
|
|
43
|
+
"""
|
|
44
|
+
p = path or FOCUS_LOG_PATH
|
|
45
|
+
try:
|
|
46
|
+
line = json.dumps(
|
|
47
|
+
{
|
|
48
|
+
"ts": now if now is not None else time.time(),
|
|
49
|
+
"session_id": session_id,
|
|
50
|
+
"provider": provider,
|
|
51
|
+
"tty": tty,
|
|
52
|
+
"tmux_target": tmux_target,
|
|
53
|
+
"attempts": [{"focuser": name, "result": result} for name, result in attempts],
|
|
54
|
+
"ok": ok,
|
|
55
|
+
"msg": msg,
|
|
56
|
+
},
|
|
57
|
+
ensure_ascii=False,
|
|
58
|
+
default=str,
|
|
59
|
+
)
|
|
60
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
_trim_if_oversized(p)
|
|
62
|
+
with p.open("a", encoding="utf-8") as f:
|
|
63
|
+
f.write(line + "\n")
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _trim_if_oversized(p: Path) -> None:
|
|
69
|
+
"""log 超過 ``_MAX_BYTES`` 時砍半保新,避免無上限成長。失敗安靜放棄(不擋 append)。"""
|
|
70
|
+
try:
|
|
71
|
+
if p.stat().st_size <= _MAX_BYTES:
|
|
72
|
+
return
|
|
73
|
+
lines = p.read_text(encoding="utf-8").splitlines()
|
|
74
|
+
keep = lines[len(lines) // 2 :]
|
|
75
|
+
tmp = p.with_suffix(".jsonl.tmp")
|
|
76
|
+
tmp.write_text("\n".join(keep) + "\n", encoding="utf-8")
|
|
77
|
+
tmp.replace(p) # atomic
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
Binary file
|
|
@@ -155,6 +155,9 @@ msgstr "Hidden {project} (no RiNG registry to clear; it reappears once there's n
|
|
|
155
155
|
msgid "已離場的 session 無法跳轉"
|
|
156
156
|
msgstr "ended sessions cannot be focused"
|
|
157
157
|
|
|
158
|
+
msgid "{app} 找到分頁了,但視窗沒浮到前景(可能在別的 Space 或全螢幕)"
|
|
159
|
+
msgstr "{app} found the tab but could not bring its window to the front (it may be on another Space or in full screen)"
|
|
160
|
+
|
|
158
161
|
msgid "找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)"
|
|
159
162
|
msgstr "terminal tab not found (it may have been closed; if it still appears after refresh, install hooks for more precise state)"
|
|
160
163
|
|
|
@@ -8,7 +8,7 @@ msgid ""
|
|
|
8
8
|
msgstr ""
|
|
9
9
|
"Project-Id-Version: PROJECT VERSION\n"
|
|
10
10
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
|
11
|
-
"POT-Creation-Date: 2026-07-
|
|
11
|
+
"POT-Creation-Date: 2026-07-25 10:42+0800\n"
|
|
12
12
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
|
13
13
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
14
14
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
|
@@ -923,18 +923,23 @@ msgstr ""
|
|
|
923
923
|
msgid "⚠️ 其中 {n} 段還在 🔴 等你(計到現在為止)。"
|
|
924
924
|
msgstr ""
|
|
925
925
|
|
|
926
|
-
#: src/ring/focus/__init__.py:
|
|
926
|
+
#: src/ring/focus/__init__.py:62
|
|
927
927
|
msgid "已離場的 session 無法跳轉"
|
|
928
928
|
msgstr ""
|
|
929
929
|
|
|
930
|
-
#: src/ring/focus/__init__.py:
|
|
930
|
+
#: src/ring/focus/__init__.py:102
|
|
931
931
|
msgid "找不到這個終端分頁(可能已關閉;刷新後若仍存在,請裝 hook 取得更精準狀態)"
|
|
932
932
|
msgstr ""
|
|
933
933
|
|
|
934
|
-
#: src/ring/focus/__init__.py:
|
|
934
|
+
#: src/ring/focus/__init__.py:103
|
|
935
935
|
msgid "沒有 focuser 接得住(裝 hook,或一個專案只開一個 session 才測得到 tty)"
|
|
936
936
|
msgstr ""
|
|
937
937
|
|
|
938
|
+
#: src/ring/focus/applescript.py:41
|
|
939
|
+
#, python-brace-format
|
|
940
|
+
msgid "{app} 找到分頁了,但視窗沒浮到前景(可能在別的 Space 或全螢幕)"
|
|
941
|
+
msgstr ""
|
|
942
|
+
|
|
938
943
|
#: src/ring/notify/__init__.py:165
|
|
939
944
|
#, python-brace-format
|
|
940
945
|
msgid "另有 {n} 個 session 在等你"
|
|
@@ -69,6 +69,8 @@ WAITING_KIND_ICONS = {
|
|
|
69
69
|
"idle": "⏸",
|
|
70
70
|
}
|
|
71
71
|
HOOK_HEARTBEAT_STALE_GRACE_SECONDS = 60.0
|
|
72
|
+
# 只送過 SessionStart 就再無下文的 hook row,寬限多久後判離場(見 _is_bare_session_start_row)
|
|
73
|
+
BARE_SESSION_START_GRACE_SECONDS = 120.0
|
|
72
74
|
|
|
73
75
|
# Provider → 「當下 live process 的 (cwd, tty) 清單」偵測器。core 不認識任何具體工具:
|
|
74
76
|
# 要支援新工具的存活偵測=註冊一個偵測器,_hook_sessions / sources 零改動。
|
|
@@ -423,6 +425,34 @@ def _hook_heartbeat_stale(
|
|
|
423
425
|
return source_mtime - heartbeat_at > grace_seconds
|
|
424
426
|
|
|
425
427
|
|
|
428
|
+
def _is_bare_session_start_row(
|
|
429
|
+
tty: str,
|
|
430
|
+
last_event: str,
|
|
431
|
+
age_seconds: float,
|
|
432
|
+
*,
|
|
433
|
+
grace_seconds: float = BARE_SESSION_START_GRACE_SECONDS,
|
|
434
|
+
) -> bool:
|
|
435
|
+
"""這筆 hook row 是不是「只送過 SessionStart 就再無下文、又沒有終端」的承載 session。
|
|
436
|
+
|
|
437
|
+
Claude Code 的背景 job/agent(``--bg-pty-host`` / ``--agent``)起來時會送一筆
|
|
438
|
+
SessionStart,之後可能整段生命週期都不再送任何事件。hook row 的狀態只靠事件推進
|
|
439
|
+
(不像 scan row 會隨 idle 秒數自己衰減),所以這種 row 會以 🟢 working 卡在看板上
|
|
440
|
+
直到 ``ACTIVE_WINDOW_SECONDS`` 過期,而且它沒有 tty、跳也跳不過去——2026-07-28
|
|
441
|
+
現場取證:一次背景 job 讓看板憑空多一列,registry 裡最久的一筆已經掛了三天。
|
|
442
|
+
|
|
443
|
+
真正在做事的背景 job 起步幾秒內就會送出 UserPromptSubmit/PreToolUse,寬限期過後
|
|
444
|
+
仍只有 SessionStart 才判離場;之後只要它真的動起來,下一個事件會把 row 寫回在場,
|
|
445
|
+
不是永久刪除。有 tty 的前景 session 一律不套用——剛開好還沒輸入的終端 session 也
|
|
446
|
+
只有 SessionStart,那是正常在場狀態。
|
|
447
|
+
|
|
448
|
+
:param tty: row 記錄到的終端;空字串代表沒有可聚焦終端。
|
|
449
|
+
:param last_event: registry 檔裡的 ``last_event``。
|
|
450
|
+
:param age_seconds: 距離 ``last_active`` 過了多久。
|
|
451
|
+
:param grace_seconds: 寬限秒數,超過才判離場。
|
|
452
|
+
"""
|
|
453
|
+
return not tty and last_event == "SessionStart" and age_seconds > grace_seconds
|
|
454
|
+
|
|
455
|
+
|
|
426
456
|
_tmux_cache: tuple[float, dict[str, str]] = (-1.0, {})
|
|
427
457
|
_tmux_panes_cache: tuple[float, list[TmuxPane]] = (-1.0, [])
|
|
428
458
|
|
|
@@ -1408,6 +1438,14 @@ def _hook_sessions(
|
|
|
1408
1438
|
# 「下一個事件來時還新不新鮮」;這裡 hook 靜默 = 同一筆請求還掛著,
|
|
1409
1439
|
# 摘要必然還是它,直接沿用)。
|
|
1410
1440
|
row.waiting_detail = pending_detail
|
|
1441
|
+
if _is_bare_session_start_row(
|
|
1442
|
+
row.tty or "",
|
|
1443
|
+
str(data.get("last_event", "")),
|
|
1444
|
+
time.time() - row.last_active,
|
|
1445
|
+
):
|
|
1446
|
+
# 背景 job/agent 的承載 session:只報到、不做事、沒有終端。判離場後
|
|
1447
|
+
# 預設收起(`--all` / TUI 的 a 仍查得到),它真的動起來時會自己回來。
|
|
1448
|
+
row.status = Status.ENDED
|
|
1411
1449
|
out.append(row)
|
|
1412
1450
|
except (KeyError, ValueError):
|
|
1413
1451
|
continue
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
"""iTerm2 focuser(macOS):用 tty 找到對應分頁並帶到前景。"""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from ring.focus.applescript import AppleScriptTTYFocuser
|
|
6
|
-
|
|
7
|
-
_SCRIPT = """
|
|
8
|
-
if application "iTerm2" is running then
|
|
9
|
-
tell application "iTerm2"
|
|
10
|
-
repeat with w in windows
|
|
11
|
-
repeat with t in tabs of w
|
|
12
|
-
repeat with s in sessions of t
|
|
13
|
-
if tty of s is "{tty}" then
|
|
14
|
-
select w
|
|
15
|
-
select t
|
|
16
|
-
select s
|
|
17
|
-
activate
|
|
18
|
-
return "ok"
|
|
19
|
-
end if
|
|
20
|
-
end repeat
|
|
21
|
-
end repeat
|
|
22
|
-
end repeat
|
|
23
|
-
end tell
|
|
24
|
-
end if
|
|
25
|
-
return "notfound"
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
focuser = AppleScriptTTYFocuser("iTerm2", _SCRIPT)
|
|
Binary file
|
|
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
|
|
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
|