ring-cli 0.15.0__tar.gz → 0.16.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.15.0 → ring_cli-0.16.0}/PKG-INFO +2 -2
- {ring_cli-0.15.0 → ring_cli-0.16.0}/README.en.md +1 -1
- {ring_cli-0.15.0 → ring_cli-0.16.0}/pyproject.toml +23 -5
- {ring_cli-0.15.0 → ring_cli-0.16.0}/pyproject.toml.orig +40 -6
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/cli.py +2 -3
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/doctor.py +15 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/config.py +1 -1
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/__init__.py +15 -4
- ring_cli-0.16.0/src/ring/focus/kitty.py +196 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/neovim.py +1 -2
- ring_cli-0.16.0/src/ring/focus/tmux.py +74 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/hook.py +3 -3
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/hook_protocol.py +1 -4
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/ipc.py +8 -16
- ring_cli-0.16.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/locale/en/LC_MESSAGES/ring.po +27 -6
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/locale/ring.pot +181 -150
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/__init__.py +5 -7
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/notify_send.py +2 -3
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/ntfy.py +2 -3
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/osascript_notifier.py +3 -3
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/terminal_notifier.py +2 -3
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/webhook.py +2 -3
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify_queue.py +5 -9
- ring_cli-0.16.0/src/ring/pathutil.py +49 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/permission.py +125 -15
- ring_cli-0.16.0/src/ring/ps_parse.py +164 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/registry.py +18 -367
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/__init__.py +8 -8
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/local_llm.py +5 -4
- ring_cli-0.16.0/src/ring/subproc.py +9 -0
- ring_cli-0.16.0/src/ring/tmux_scan.py +186 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/tui.py +45 -19
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/watcher.py +1 -1
- ring_cli-0.15.0/src/ring/focus/tmux.py +0 -38
- ring_cli-0.15.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/LICENSE +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/quiet.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/focus/trace.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/gc.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/labels.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/payload_log.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/plugins.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/question_detect.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/stats.py +0 -0
- {ring_cli-0.15.0 → ring_cli-0.16.0}/src/ring/transcript.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ring-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: RiNG — Realtime Instance Notification Grid for active agent CLI sessions.
|
|
5
5
|
Keywords: claude-code,codex,ollama,llama-cpp,tui,dashboard,session,monitor,rich
|
|
6
6
|
Author: Wei Lee
|
|
@@ -50,7 +50,7 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
|
|
|
50
50
|
- **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
|
|
51
51
|
detection for interactive Ollama and llama.cpp CLIs.
|
|
52
52
|
- **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
|
|
53
|
-
- **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
|
|
53
|
+
- **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, kitty, Neovim terminals, or Linux X11 windows from the TUI.
|
|
54
54
|
- **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
|
|
55
55
|
- **Fits existing workflows**: status-bar output, JSON, a provider-neutral hook, and plugin extension points.
|
|
56
56
|
|
|
@@ -24,7 +24,7 @@ When several Claude Code, Codex, or local-model sessions are running, RiNG shows
|
|
|
24
24
|
- **One board for session state**: built-in sources for Claude Code and Codex, plus process-liveness
|
|
25
25
|
detection for interactive Ollama and llama.cpp CLIs.
|
|
26
26
|
- **Puts waiting sessions first**: shows what 🔴 sessions need and sends desktop or phone notifications.
|
|
27
|
-
- **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, Neovim terminals, or Linux X11 windows from the TUI.
|
|
27
|
+
- **Returns you to the right terminal**: focuses tmux, iTerm2, Terminal.app, kitty, Neovim terminals, or Linux X11 windows from the TUI.
|
|
28
28
|
- **Avoids an extra context switch**: reply to permission requests in supported terminals and assign session names from the board.
|
|
29
29
|
- **Fits existing workflows**: status-bar output, JSON, a provider-neutral hook, and plugin extension points.
|
|
30
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ring-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.16.0"
|
|
4
4
|
description = "RiNG — Realtime Instance Notification Grid for active agent CLI sessions."
|
|
5
5
|
readme = "README.en.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -106,13 +106,23 @@ select = [
|
|
|
106
106
|
"I",
|
|
107
107
|
"UP",
|
|
108
108
|
"RUF",
|
|
109
|
+
"B",
|
|
110
|
+
"C4",
|
|
111
|
+
"SIM",
|
|
112
|
+
"PTH",
|
|
113
|
+
"ARG",
|
|
114
|
+
"TRY",
|
|
109
115
|
]
|
|
110
116
|
ignore = [
|
|
111
117
|
"RUF001",
|
|
112
118
|
"RUF002",
|
|
113
119
|
"RUF003",
|
|
120
|
+
"TRY003",
|
|
114
121
|
]
|
|
115
122
|
|
|
123
|
+
[tool.ruff.lint.per-file-ignores]
|
|
124
|
+
"tests/**" = ["ARG"]
|
|
125
|
+
|
|
116
126
|
[tool.codespell]
|
|
117
127
|
skip = ".git*,*.lock"
|
|
118
128
|
check-hidden = true
|
|
@@ -152,16 +162,20 @@ sequence = [
|
|
|
152
162
|
"cover",
|
|
153
163
|
]
|
|
154
164
|
|
|
165
|
+
[tool.poe.tasks.hooks]
|
|
166
|
+
help = "Run every pre-commit hook against all files"
|
|
167
|
+
cmd = "prek run --all-files"
|
|
168
|
+
|
|
169
|
+
[tool.poe.tasks.hooks.env]
|
|
170
|
+
SKIP = "no-commit-to-branch"
|
|
171
|
+
|
|
155
172
|
[tool.poe.tasks.ci]
|
|
156
173
|
help = "Run all tasks in CI"
|
|
157
174
|
sequence = [
|
|
158
|
-
|
|
175
|
+
"hooks",
|
|
159
176
|
"cover",
|
|
160
177
|
]
|
|
161
178
|
|
|
162
|
-
[tool.poe.tasks.ci.env]
|
|
163
|
-
SKIP = "no-commit-to-branch"
|
|
164
|
-
|
|
165
179
|
[tool.poe.tasks.setup-pre-commit]
|
|
166
180
|
help = "Install pre-commit hooks"
|
|
167
181
|
cmd = "prek install"
|
|
@@ -204,9 +218,13 @@ addopts = [
|
|
|
204
218
|
|
|
205
219
|
[tool.coverage.run]
|
|
206
220
|
source = ["ring"]
|
|
221
|
+
branch = true
|
|
207
222
|
|
|
208
223
|
[tool.coverage.paths]
|
|
209
224
|
source = [
|
|
210
225
|
"src/ring",
|
|
211
226
|
"*/ring",
|
|
212
227
|
]
|
|
228
|
+
|
|
229
|
+
[tool.coverage.report]
|
|
230
|
+
fail_under = 85
|
|
@@ -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.16.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"
|
|
@@ -81,9 +81,33 @@ target-version = "py313"
|
|
|
81
81
|
line-length = 120
|
|
82
82
|
|
|
83
83
|
[tool.ruff.lint]
|
|
84
|
-
select = [
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
select = [
|
|
85
|
+
"E",
|
|
86
|
+
"F",
|
|
87
|
+
"W",
|
|
88
|
+
"I",
|
|
89
|
+
"UP",
|
|
90
|
+
"RUF",
|
|
91
|
+
"B",
|
|
92
|
+
"C4",
|
|
93
|
+
"SIM",
|
|
94
|
+
"PTH",
|
|
95
|
+
"ARG",
|
|
96
|
+
"TRY",
|
|
97
|
+
]
|
|
98
|
+
ignore = [
|
|
99
|
+
# 本專案刻意大量使用台灣漢語註解/字串,關掉「模糊 unicode 字元」誤報(全形標點等)。
|
|
100
|
+
"RUF001",
|
|
101
|
+
"RUF002",
|
|
102
|
+
"RUF003",
|
|
103
|
+
# raise-vanilla-args:ring 是 CLI,例外訊息就是給人看的字串,
|
|
104
|
+
# 為此替每個 raise 造一個自訂例外類別並不划算。
|
|
105
|
+
"TRY003",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[tool.ruff.lint.per-file-ignores]
|
|
109
|
+
# monkeypatch 的替身要符合被替換函式的簽名,未使用的參數是刻意保留的。
|
|
110
|
+
"tests/**" = ["ARG"]
|
|
87
111
|
|
|
88
112
|
[tool.codespell]
|
|
89
113
|
skip = '.git*,*.lock'
|
|
@@ -107,9 +131,13 @@ cover.cmd = "pytest --cov=ring --cov-report=term-missing --cov-report=xml:covera
|
|
|
107
131
|
all.help = "Run all tasks"
|
|
108
132
|
all.sequence = ["format", "lint", "cover"]
|
|
109
133
|
|
|
134
|
+
hooks.help = "Run every pre-commit hook against all files"
|
|
135
|
+
hooks.cmd = "prek run --all-files"
|
|
136
|
+
# no-commit-to-branch 只對本機 commit 有意義;CI 是 detached HEAD,跑它只會誤擋。
|
|
137
|
+
hooks.env = { SKIP = "no-commit-to-branch" }
|
|
138
|
+
|
|
110
139
|
ci.help = "Run all tasks in CI"
|
|
111
|
-
ci.sequence = [
|
|
112
|
-
ci.env = { SKIP = "no-commit-to-branch" }
|
|
140
|
+
ci.sequence = ["hooks", "cover"]
|
|
113
141
|
|
|
114
142
|
setup-pre-commit.help = "Install pre-commit hooks"
|
|
115
143
|
setup-pre-commit.cmd = "prek install"
|
|
@@ -143,6 +171,12 @@ addopts = ["-n", "auto", "--dist=loadfile"]
|
|
|
143
171
|
|
|
144
172
|
[tool.coverage.run]
|
|
145
173
|
source = ["ring"]
|
|
174
|
+
branch = true
|
|
146
175
|
|
|
147
176
|
[tool.coverage.paths]
|
|
148
177
|
source = ["src/ring", "*/ring"]
|
|
178
|
+
|
|
179
|
+
[tool.coverage.report]
|
|
180
|
+
# 實測(開 branch coverage、補完 focus/neovim 測試後)總覆蓋率 89%,
|
|
181
|
+
# 門檻壓在 85 留一點正常波動的空間。
|
|
182
|
+
fail_under = 85
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
|
+
import contextlib
|
|
13
14
|
import json
|
|
14
15
|
import sys
|
|
15
16
|
import time
|
|
@@ -408,10 +409,8 @@ def _watch_flush_if_due() -> None:
|
|
|
408
409
|
``test_run_hook_does_not_flush_while_quiet_active`` 同機制),這裡不需另外判斷。
|
|
409
410
|
失敗安靜吞掉,不影響看板本身。
|
|
410
411
|
"""
|
|
411
|
-
|
|
412
|
+
with contextlib.suppress(Exception):
|
|
412
413
|
flush_if_due()
|
|
413
|
-
except Exception:
|
|
414
|
-
pass
|
|
415
414
|
|
|
416
415
|
|
|
417
416
|
def watch(interval: float, count: int, show_all: bool, show_legend: bool) -> int:
|
|
@@ -7,6 +7,7 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
from ring.commands._args import strip_lang
|
|
9
9
|
from ring.config import CONFIG_PATH, get_config
|
|
10
|
+
from ring.focus.kitty import sockets as kitty_sockets
|
|
10
11
|
from ring.gc import DEFAULT_OLDER_THAN_SECONDS
|
|
11
12
|
from ring.gc import collect_candidates as gc_collect_candidates
|
|
12
13
|
from ring.i18n import gettext as _
|
|
@@ -110,6 +111,20 @@ def run_doctor(args: list[str]) -> int:
|
|
|
110
111
|
elif name_lower == "tmux":
|
|
111
112
|
avail = shutil.which("tmux") is not None
|
|
112
113
|
avail_str = _("可用") if avail else _("不可用(tmux 不在 PATH)")
|
|
114
|
+
elif name_lower == "kitty":
|
|
115
|
+
if shutil.which("kitty") is None:
|
|
116
|
+
avail_str = _("不可用(kitty 不在 PATH)")
|
|
117
|
+
elif not kitty_sockets():
|
|
118
|
+
avail_str = _("不可用(kitty remote control 沒開:kitty.conf 要設 allow_remote_control + listen_on)")
|
|
119
|
+
else:
|
|
120
|
+
avail_str = _("可用")
|
|
121
|
+
elif name_lower == "linux-wm":
|
|
122
|
+
if not sys.platform.startswith("linux"):
|
|
123
|
+
avail_str = _("不可用(非 Linux)")
|
|
124
|
+
elif shutil.which("wmctrl") is None:
|
|
125
|
+
avail_str = _("不可用(wmctrl 不在 PATH)")
|
|
126
|
+
else:
|
|
127
|
+
avail_str = _("可用")
|
|
113
128
|
elif shutil.which("osascript") is None:
|
|
114
129
|
avail_str = _("不可用(osascript 不在 PATH)")
|
|
115
130
|
else:
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
notify_ntfy_url = "https://ntfy.sh/my-topic" # 設了才啟用 ntfy 後端(推到手機)
|
|
35
35
|
notify_webhook_url = "https://example.com/hook" # 設了才啟用 webhook 後端(JSON POST)
|
|
36
36
|
notify_also = ["ntfy"] # 主後端之外「加發」的後端(例如桌面通知+手機各一份)
|
|
37
|
-
focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
|
|
37
|
+
focusers = ["Neovim", "tmux", "iTerm2", "Terminal", "kitty", "linux-wm"] # 跳轉嘗試順序;省略=內建預設
|
|
38
38
|
plugins = ["my_ring_plugin"] # 啟動時 import 的外部 plugin 模組(自行 register_*)
|
|
39
39
|
debug_payload_log = false # 診斷用:記錄 hook 收到的原始 payload
|
|
40
40
|
# (見 payload_log.py,寫到 ~/.config/ring/hook_payloads.jsonl);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
每個終端是一個 ``Focuser``(見 ``base.Focuser``):core 不認識任何具體終端,
|
|
4
4
|
只依序問每個 focuser「這個 session 歸不歸你管」。要支援新終端=寫一個模組、
|
|
5
5
|
``register_focuser()`` 註冊,core 零改動。內建:Neovim terminal / tmux / iTerm2 /
|
|
6
|
-
Terminal.app / Linux X11 視窗(wmctrl,best-effort fallback)。
|
|
6
|
+
Terminal.app / kitty(remote control socket)/ Linux X11 視窗(wmctrl,best-effort fallback)。
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
from ring.config import get_config
|
|
12
12
|
from ring.focus.base import Focuser
|
|
13
13
|
from ring.focus.iterm2 import focuser as _iterm2
|
|
14
|
+
from ring.focus.kitty import focuser as _kitty
|
|
14
15
|
from ring.focus.linux_wm import focuser as _linux_wm
|
|
15
16
|
from ring.focus.neovim import focuser as _neovim
|
|
16
17
|
from ring.focus.terminal import focuser as _terminal
|
|
@@ -20,12 +21,13 @@ from ring.i18n import gettext as _
|
|
|
20
21
|
from ring.registry import Session, Status
|
|
21
22
|
|
|
22
23
|
# 內建 focuser。順序可由 config 的 `focusers` 覆寫。Neovim 先切內層 terminal buffer,
|
|
23
|
-
# 再由 tmux / macOS app / linux-wm 把外層帶到前景。
|
|
24
|
+
# 再由 tmux / macOS app / kitty / linux-wm 把外層帶到前景。
|
|
24
25
|
_BUILTIN: dict[str, Focuser] = {
|
|
25
26
|
"Neovim": _neovim,
|
|
26
27
|
"tmux": _tmux,
|
|
27
28
|
"iTerm2": _iterm2,
|
|
28
29
|
"Terminal": _terminal,
|
|
30
|
+
"kitty": _kitty,
|
|
29
31
|
"linux-wm": _linux_wm,
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -99,8 +101,17 @@ def jump(session: Session) -> tuple[bool, str]:
|
|
|
99
101
|
if failures:
|
|
100
102
|
return _done(False, "; ".join(failures))
|
|
101
103
|
if session.tty:
|
|
102
|
-
return _done(
|
|
103
|
-
|
|
104
|
+
return _done(
|
|
105
|
+
False,
|
|
106
|
+
_("找不到這個終端分頁(可能已關閉;刷新後若仍在,跑 `ring doctor` 檢查 focuser 與 hook 狀態)"),
|
|
107
|
+
)
|
|
108
|
+
return _done(
|
|
109
|
+
False,
|
|
110
|
+
_(
|
|
111
|
+
"沒有 focuser 接得住(跑 `ring doctor` 看哪些 focuser 可用;"
|
|
112
|
+
"裝 hook、或一個專案只開一個 session 才測得到 tty)"
|
|
113
|
+
),
|
|
114
|
+
)
|
|
104
115
|
|
|
105
116
|
|
|
106
117
|
__all__ = ["Focuser", "focusers", "jump", "register_focuser"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""kitty 終端 focuser:透過 remote control socket 精準跳到分頁。
|
|
2
|
+
|
|
3
|
+
定位:kitty 官方沒有 tmux 那種「切換 client」指令,也沒有 iTerm2 / Terminal.app 的 AppleScript
|
|
4
|
+
可查詢;能精準定位到分頁的唯一途徑是它的 remote control 協定——連上 unix socket、列出視窗、
|
|
5
|
+
比對 tty,再叫它 focus-window。
|
|
6
|
+
|
|
7
|
+
機制與限制(誠實標明):
|
|
8
|
+
- **kitty 預設關閉 remote control**,要在 kitty.conf 設 ``allow_remote_control`` 為
|
|
9
|
+
``yes`` / ``socket`` / ``socket-only``,並設 ``listen_on``(例:
|
|
10
|
+
``listen_on unix:/tmp/kitty-{kitty_pid}``)才會建立 socket;沒設就會安靜地跳過(回 ``None``),
|
|
11
|
+
不會顯示錯誤。
|
|
12
|
+
- 只掃 ``/tmp`` 底下的 ``kitty-*`` socket(外加 ``$KITTY_LISTEN_ON`` 這條環境變數,但 RiNG
|
|
13
|
+
通常跑在 kitty 之外,這個變數多半是空的);``listen_on`` 指到別的目錄就配不到。
|
|
14
|
+
- 逐個 socket 問一輪:多個 kitty 實例的 socket 互不可見,沒有統一查詢點。
|
|
15
|
+
- 用 ``window["pid"]``/``window["foreground_processes"][]["pid"]`` 反查 tty 跟 session 比對,
|
|
16
|
+
找到後呼叫 ``focus-window``;macOS 上固定補一次 ``osascript activate``(視窗縮到 Dock 時
|
|
17
|
+
``focus-window`` 只負責取消縮小、不負責把整個 app 帶到最前,已實測)。
|
|
18
|
+
- **Linux 端未實測**:``focus-window`` 底層走同一套 remote-control 協定,理論上等同
|
|
19
|
+
``wmctrl -i -a`` 的效果,但這只是推論。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import stat
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from ring.osascript import osascript
|
|
34
|
+
from ring.registry import Session
|
|
35
|
+
|
|
36
|
+
_SOCKET_DIRS: tuple[Path, ...] = (Path("/tmp"),) # 之後要支援自訂 listen_on 目錄就加在這裡
|
|
37
|
+
_MAX_SOCKETS = 8
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run(cmd: list[str]) -> subprocess.CompletedProcess[str] | None:
|
|
41
|
+
"""跑一個唯讀/聚焦指令,成功回 ``CompletedProcess``(不論 returncode),失敗回 ``None``。"""
|
|
42
|
+
try:
|
|
43
|
+
return subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
44
|
+
except (OSError, subprocess.SubprocessError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_socket(path: Path) -> bool:
|
|
49
|
+
try:
|
|
50
|
+
return stat.S_ISSOCK(path.lstat().st_mode)
|
|
51
|
+
except OSError:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sockets() -> list[str]:
|
|
56
|
+
"""候選 kitty remote-control socket 路徑,依 mtime 新到舊排序,上限 ``_MAX_SOCKETS``。
|
|
57
|
+
|
|
58
|
+
候選來源:``$KITTY_LISTEN_ON``(去掉 ``unix:`` 前綴、需為絕對路徑)+ ``/tmp/kitty-*`` glob。
|
|
59
|
+
一律用 ``stat.S_ISSOCK`` 過濾——``/tmp`` 下可能有 ``kitty-iterm-migration``、
|
|
60
|
+
``kitty-swift-module-cache`` 這種同前綴的普通目錄,不濾會誤連。呼叫時才做 I/O,
|
|
61
|
+
模組 import 時不碰檔案系統。
|
|
62
|
+
"""
|
|
63
|
+
candidates: list[Path] = []
|
|
64
|
+
listen_on = os.environ.get("KITTY_LISTEN_ON", "")
|
|
65
|
+
if listen_on.startswith("unix:"):
|
|
66
|
+
listen_on = listen_on.removeprefix("unix:")
|
|
67
|
+
if listen_on and Path(listen_on).is_absolute():
|
|
68
|
+
candidates.append(Path(listen_on))
|
|
69
|
+
for socket_dir in _SOCKET_DIRS:
|
|
70
|
+
try:
|
|
71
|
+
candidates.extend(sorted(socket_dir.glob("kitty-*")))
|
|
72
|
+
except OSError:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
seen: set[Path] = set()
|
|
76
|
+
found: list[tuple[float, Path]] = []
|
|
77
|
+
for path in candidates:
|
|
78
|
+
if path in seen or not _is_socket(path):
|
|
79
|
+
continue
|
|
80
|
+
seen.add(path)
|
|
81
|
+
try:
|
|
82
|
+
mtime = path.stat().st_mtime
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
found.append((mtime, path))
|
|
86
|
+
found.sort(key=lambda item: item[0], reverse=True)
|
|
87
|
+
return [str(path) for _mtime, path in found[:_MAX_SOCKETS]]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _pid_tty_map() -> dict[int, str]:
|
|
91
|
+
"""一次性 ``ps -eo pid=,tty=`` 快照:pid → ``/dev/ttyXXX``(``?``/``??`` 視為無 tty)。"""
|
|
92
|
+
result = _run(["ps", "-eo", "pid=,tty="])
|
|
93
|
+
table: dict[int, str] = {}
|
|
94
|
+
if result is None or result.returncode != 0:
|
|
95
|
+
return table
|
|
96
|
+
for line in result.stdout.splitlines():
|
|
97
|
+
parts = line.split()
|
|
98
|
+
if len(parts) != 2:
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
pid = int(parts[0])
|
|
102
|
+
except ValueError:
|
|
103
|
+
continue
|
|
104
|
+
raw_tty = parts[1]
|
|
105
|
+
if not raw_tty or raw_tty in {"?", "??"}:
|
|
106
|
+
continue
|
|
107
|
+
table[pid] = raw_tty if raw_tty.startswith("/dev/") else f"/dev/{raw_tty}"
|
|
108
|
+
return table
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _window_matches_tty(window: dict[str, Any], tty: str, pid_tty: dict[int, str]) -> bool:
|
|
112
|
+
pids: list[Any] = [window.get("pid")]
|
|
113
|
+
foreground = window.get("foreground_processes")
|
|
114
|
+
if isinstance(foreground, list):
|
|
115
|
+
for proc in foreground:
|
|
116
|
+
if isinstance(proc, dict):
|
|
117
|
+
pids.append(proc.get("pid"))
|
|
118
|
+
return any(isinstance(pid, int) and pid_tty.get(pid) == tty for pid in pids)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _match_window(os_windows: Any, tty: str, pid_tty: dict[int, str]) -> int | None:
|
|
122
|
+
"""走訪 ``kitty @ ls`` 的 JSON(OS window → tabs[] → windows[]),回第一個比對到 tty 的 window id。"""
|
|
123
|
+
if not isinstance(os_windows, list):
|
|
124
|
+
return None
|
|
125
|
+
for os_window in os_windows:
|
|
126
|
+
if not isinstance(os_window, dict):
|
|
127
|
+
continue
|
|
128
|
+
tabs = os_window.get("tabs")
|
|
129
|
+
if not isinstance(tabs, list):
|
|
130
|
+
continue
|
|
131
|
+
for tab in tabs:
|
|
132
|
+
if not isinstance(tab, dict):
|
|
133
|
+
continue
|
|
134
|
+
windows = tab.get("windows")
|
|
135
|
+
if not isinstance(windows, list):
|
|
136
|
+
continue
|
|
137
|
+
for window in windows:
|
|
138
|
+
if not isinstance(window, dict):
|
|
139
|
+
continue
|
|
140
|
+
if _window_matches_tty(window, tty, pid_tty):
|
|
141
|
+
win_id = window.get("id")
|
|
142
|
+
if isinstance(win_id, int):
|
|
143
|
+
return win_id
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve_window(tty: str) -> tuple[str, int] | None:
|
|
148
|
+
"""tty → ``(socket 路徑, kitty window id)``;沒裝 kitty/沒 socket/配不到 → ``None``。
|
|
149
|
+
|
|
150
|
+
這段定位邏輯同時給 ``focus``(聚焦分頁)與 ``permission``(抓畫面/送鍵)兩邊共用,
|
|
151
|
+
kitty ``ls`` 的 JSON 形狀知識只此一份,兩邊不重寫第二份。
|
|
152
|
+
"""
|
|
153
|
+
if not tty or shutil.which("kitty") is None:
|
|
154
|
+
return None
|
|
155
|
+
socks = sockets()
|
|
156
|
+
if not socks:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
pid_tty = _pid_tty_map()
|
|
160
|
+
|
|
161
|
+
for sock in socks:
|
|
162
|
+
result = _run(["kitty", "@", "--to", f"unix:{sock}", "ls"])
|
|
163
|
+
if result is None or result.returncode != 0:
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
os_windows = json.loads(result.stdout)
|
|
167
|
+
except json.JSONDecodeError:
|
|
168
|
+
continue
|
|
169
|
+
win_id = _match_window(os_windows, tty, pid_tty)
|
|
170
|
+
if win_id is None:
|
|
171
|
+
continue
|
|
172
|
+
return sock, win_id
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class KittyFocuser:
|
|
177
|
+
name = "kitty"
|
|
178
|
+
|
|
179
|
+
def try_focus(self, session: Session) -> tuple[bool, str] | None:
|
|
180
|
+
tty = session.tty
|
|
181
|
+
if not tty:
|
|
182
|
+
return None
|
|
183
|
+
resolved = resolve_window(tty)
|
|
184
|
+
if resolved is None:
|
|
185
|
+
return None
|
|
186
|
+
sock, win_id = resolved
|
|
187
|
+
|
|
188
|
+
focus_result = _run(["kitty", "@", "--to", f"unix:{sock}", "focus-window", "--match", f"id:{win_id}"])
|
|
189
|
+
if focus_result is None or focus_result.returncode != 0:
|
|
190
|
+
return False, f"kitty focus-window failed for id:{win_id}"
|
|
191
|
+
if sys.platform == "darwin" and shutil.which("osascript"):
|
|
192
|
+
osascript('tell application "kitty" to activate')
|
|
193
|
+
return True, f"{self.name} {win_id}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
focuser = KittyFocuser()
|
|
@@ -9,7 +9,6 @@ iTerm2 / Terminal.app 把外層 pane 或視窗帶到前景。
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
-
import os
|
|
13
12
|
import re
|
|
14
13
|
import subprocess
|
|
15
14
|
import sys
|
|
@@ -57,7 +56,7 @@ def _process_table() -> tuple[dict[int, int], dict[int, str]]:
|
|
|
57
56
|
except ValueError:
|
|
58
57
|
continue
|
|
59
58
|
parents[pid] = ppid
|
|
60
|
-
commands[pid] =
|
|
59
|
+
commands[pid] = Path(parts[2]).name
|
|
61
60
|
return parents, commands
|
|
62
61
|
|
|
63
62
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""tmux focuser:同一個 tmux server 直接 switch-client 切到那個 pane。
|
|
2
|
+
|
|
3
|
+
成功後不中止 dispatcher(``continue_after_success``):tmux 只能切 pane,換不了外層終端
|
|
4
|
+
視窗本身有沒有浮到前景,所以讓 iTerm2 / Terminal.app / kitty / linux-wm 等外層 focuser
|
|
5
|
+
接著跑,才有機會把視窗帶到前景(設計依據見 ``focus/base.py:8-9``)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from ring.registry import Session
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _client_tty(tmux_session: str, target: str) -> str:
|
|
17
|
+
"""查這個 tmux session 目前的 client tty,回填給外層 focuser 配對用。
|
|
18
|
+
|
|
19
|
+
多個 client attach 同一個 session 時取第一個非空值;查不到(沒有 client、指令失敗、
|
|
20
|
+
逾時)一律回空字串,呼叫端不得把這種情況當成 switch-client 失敗。
|
|
21
|
+
"""
|
|
22
|
+
for cmd in (
|
|
23
|
+
["tmux", "list-clients", "-t", tmux_session, "-F", "#{client_tty}"],
|
|
24
|
+
["tmux", "display-message", "-p", "-t", target, "#{client_tty}"],
|
|
25
|
+
):
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
28
|
+
except (OSError, subprocess.SubprocessError):
|
|
29
|
+
continue
|
|
30
|
+
if result.returncode != 0:
|
|
31
|
+
continue
|
|
32
|
+
for line in result.stdout.splitlines():
|
|
33
|
+
line = line.strip()
|
|
34
|
+
if line:
|
|
35
|
+
return line
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TmuxFocuser:
|
|
40
|
+
name = "tmux"
|
|
41
|
+
continue_after_success = True
|
|
42
|
+
|
|
43
|
+
def try_focus(self, session: Session) -> tuple[bool, str] | None:
|
|
44
|
+
target = session.tmux_target
|
|
45
|
+
if not target or not shutil.which("tmux"):
|
|
46
|
+
return None
|
|
47
|
+
tmux_session = target.split(":", 1)[0]
|
|
48
|
+
window = target.split(".", 1)[0]
|
|
49
|
+
ok = False
|
|
50
|
+
last_err = ""
|
|
51
|
+
for cmd in (
|
|
52
|
+
["tmux", "switch-client", "-t", tmux_session],
|
|
53
|
+
["tmux", "select-window", "-t", window],
|
|
54
|
+
["tmux", "select-pane", "-t", target],
|
|
55
|
+
):
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
|
|
58
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
59
|
+
return False, str(exc)
|
|
60
|
+
if result.returncode == 0:
|
|
61
|
+
ok = True
|
|
62
|
+
else:
|
|
63
|
+
last_err = result.stderr.strip()
|
|
64
|
+
if not ok:
|
|
65
|
+
return False, last_err or "tmux switch failed"
|
|
66
|
+
# switch-client 之後,接上這個 tmux session 的 client 的 tty 就是那個終端視窗的 tty。
|
|
67
|
+
# 回填給後面的外層 focuser 配對用(同 neovim.py:176-179 的做法)。
|
|
68
|
+
client_tty = _client_tty(tmux_session, target)
|
|
69
|
+
if client_tty.startswith("/dev/"):
|
|
70
|
+
session.tty = client_tty
|
|
71
|
+
return True, f"tmux {target}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
focuser = TmuxFocuser()
|
|
@@ -120,7 +120,7 @@ def _ps_row(pid: int) -> tuple[int, str] | None:
|
|
|
120
120
|
def _session_tty(process_names: tuple[str, ...]) -> str:
|
|
121
121
|
"""hook 是 agent CLI 的後代——往上找到 provider process,回它的控制終端 tty。
|
|
122
122
|
|
|
123
|
-
這是「session → 哪個終端」最精準的對應(不必靠 cwd
|
|
123
|
+
這是「session → 哪個終端」最精準的對應(不必靠 cwd 猜),給非-tmux 終端 focuser(iTerm2 等)跳轉用。
|
|
124
124
|
"""
|
|
125
125
|
if not process_names:
|
|
126
126
|
return ""
|
|
@@ -130,7 +130,7 @@ def _session_tty(process_names: tuple[str, ...]) -> str:
|
|
|
130
130
|
if row is None:
|
|
131
131
|
return ""
|
|
132
132
|
ppid, comm = row
|
|
133
|
-
if
|
|
133
|
+
if Path(comm.strip()).name in process_names:
|
|
134
134
|
return _pid_tty(pid)
|
|
135
135
|
if ppid <= 1:
|
|
136
136
|
return ""
|
|
@@ -496,7 +496,7 @@ def _is_ring_hook_command(cmd: str) -> bool:
|
|
|
496
496
|
tokens = cmd.split()
|
|
497
497
|
if len(tokens) < 2:
|
|
498
498
|
return False
|
|
499
|
-
return
|
|
499
|
+
return Path(tokens[0]).name == "ring" and tokens[1] == "hook"
|
|
500
500
|
|
|
501
501
|
|
|
502
502
|
# install-hooks 會就「使用者互動」事件警告:別的工具若也掛在這上面(彈自己的對話框 /
|
|
@@ -309,10 +309,7 @@ def _action_detail(data: Mapping[str, Any]) -> str:
|
|
|
309
309
|
if not detail:
|
|
310
310
|
detail = _first_str(data, "message", "prompt", "question")
|
|
311
311
|
|
|
312
|
-
if tool and detail
|
|
313
|
-
out = f"{tool}: {detail}"
|
|
314
|
-
else:
|
|
315
|
-
out = tool or detail
|
|
312
|
+
out = f"{tool}: {detail}" if tool and detail else tool or detail
|
|
316
313
|
out = " ".join(out.split()) # 多行指令壓成單行
|
|
317
314
|
return out if len(out) <= _DETAIL_MAX else out[: _DETAIL_MAX - 1] + "…"
|
|
318
315
|
|