ring-cli 0.10.0__tar.gz → 0.11.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.10.0 → ring_cli-0.11.0}/PKG-INFO +27 -2
- {ring_cli-0.10.0 → ring_cli-0.11.0}/README.en.md +26 -1
- {ring_cli-0.10.0 → ring_cli-0.11.0}/pyproject.toml +1 -1
- ring_cli-0.11.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/locale/en/LC_MESSAGES/ring.po +39 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/locale/ring.pot +104 -40
- ring_cli-0.11.0/src/ring/permission.py +334 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/tui.py +133 -2
- ring_cli-0.10.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/LICENSE +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/__init__.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/__main__.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/cli.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/__init__.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/_args.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/completion.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/digest.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/doctor.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/focus.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/gc.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/hook.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/stats.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/config.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/__init__.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/applescript.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/base.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/iterm2.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/linux_wm.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/neovim.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/terminal.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/tmux.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/gc.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/hook.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/hook_protocol.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/i18n.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/ipc.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/labels.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/__init__.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/base.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/command.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/notify_send.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/ntfy.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/osascript_notifier.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/terminal_notifier.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/webhook.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/osascript.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/plugins.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/registry.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/__init__.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/base.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/claude_code.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/codex.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/hook_registry.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/stats.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/transcript.py +0 -0
- {ring_cli-0.10.0 → ring_cli-0.11.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.11.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
|
|
@@ -59,6 +59,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
|
|
|
59
59
|
- **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
|
|
60
60
|
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, Terminal.app (macOS), or a Linux X11 window (`wmctrl`).
|
|
61
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.
|
|
62
|
+
- **Reply to permission requests in place**: with the cursor on a 🔴 waiting row, press `p` — RiNG reads the permission dialog from that session's terminal screen, pops the numbered options in a modal, and presses your choice for you, so you don't have to jump over one by one (sessions inside tmux, plus plain iTerm2 tabs on macOS).
|
|
62
63
|
- **Name your sessions**: press `n` in the TUI to name a session, e.g. "auth refactor"; once named, the board and notifications show the name instead of the workspace directory.
|
|
63
64
|
- **See what it is waiting for**: in hook mode, a 🔴 waiting session carries the concrete pending item (the command to run, the question asked) — shown in the TUI and in the notification body.
|
|
64
65
|
- **Fits your status bar**: `ring --format oneline` prints a `🔴2 🟢1 🟡3` one-liner for tmux / SwiftBar / waybar; `--format json` feeds scripts.
|
|
@@ -153,7 +154,7 @@ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a
|
|
|
153
154
|
## Watch Mode
|
|
154
155
|
|
|
155
156
|
- With **Textual** (`[tui]` extra) in a real terminal: interactive TUI.
|
|
156
|
-
Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
|
|
157
|
+
Use `↑/↓` to select, `Enter` / `Space` to jump, `p` to reply to a permission request in place, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
|
|
157
158
|
If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
|
|
158
159
|
When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
|
|
159
160
|
- Otherwise: Rich polling; without Rich, plain text.
|
|
@@ -172,6 +173,30 @@ hands the request to the TUI so it selects that session; otherwise it focuses th
|
|
|
172
173
|
TTY matching is most accurate in hook mode. Without hooks, Codex falls back to zero-config matching:
|
|
173
174
|
one live Codex session per cwd can jump correctly; multiple live Codex sessions in the same cwd are shown conservatively to avoid focusing the wrong tab.
|
|
174
175
|
|
|
176
|
+
### Reply To Permission Requests In Place (`p`)
|
|
177
|
+
|
|
178
|
+
With the cursor on a 🔴 waiting row, press `p`. RiNG reads that session's screen, parses the
|
|
179
|
+
permission dialog (Claude Code's "Do you want to proceed?" box, including background-subagent
|
|
180
|
+
ones with a "from the … agent" header), and lists the numbered options verbatim in a modal.
|
|
181
|
+
After you pick one, RiNG **captures the screen again** to confirm the dialog is still there and
|
|
182
|
+
unchanged (it may have been answered while you were deciding), only then sends that single
|
|
183
|
+
digit, and re-checks that the dialog actually disappeared.
|
|
184
|
+
|
|
185
|
+
- **Sessions inside tmux**: reads the screen with `tmux capture-pane` and sends keys with
|
|
186
|
+
`tmux send-keys`.
|
|
187
|
+
- **Sessions in a plain iTerm2 tab on macOS** (no tmux): locates the matching iTerm2 session by
|
|
188
|
+
its `tty` via AppleScript, and both reads the screen and sends keys through `osascript`. The
|
|
189
|
+
first use triggers the macOS Automation permission prompt ("allow control of iTerm2") — allow
|
|
190
|
+
it once.
|
|
191
|
+
|
|
192
|
+
Safety first: if no recognizable dialog can be parsed (missing markers, numbering, or cursor),
|
|
193
|
+
RiNG only shows a toast and **never sends a key** — without the dialog, keystrokes would land in
|
|
194
|
+
the chat input box as text. If the dialog happens to vanish in the instant the digit is sent and
|
|
195
|
+
the digit lands in the input box, RiNG sends a Backspace to clean it up and warns you.
|
|
196
|
+
|
|
197
|
+
**Limits**: reading the screen requires a tmux pane coordinate, or (on macOS) an iTerm2 session
|
|
198
|
+
with a detectable tty; for other sessions, press `Enter` to jump over and reply there.
|
|
199
|
+
|
|
175
200
|
### Notifications
|
|
176
201
|
|
|
177
202
|
Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
|
|
@@ -34,6 +34,7 @@ RiNG puts them on one board, with sessions waiting for you sorted first.
|
|
|
34
34
|
- **Waiting first**: sessions that need your response are highlighted and sorted above the rest.
|
|
35
35
|
- **Jump back to the terminal**: in the TUI, select a session and press `Enter` / `Space` to focus tmux, iTerm2, Terminal.app (macOS), or a Linux X11 window (`wmctrl`).
|
|
36
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.
|
|
37
|
+
- **Reply to permission requests in place**: with the cursor on a 🔴 waiting row, press `p` — RiNG reads the permission dialog from that session's terminal screen, pops the numbered options in a modal, and presses your choice for you, so you don't have to jump over one by one (sessions inside tmux, plus plain iTerm2 tabs on macOS).
|
|
37
38
|
- **Name your sessions**: press `n` in the TUI to name a session, e.g. "auth refactor"; once named, the board and notifications show the name instead of the workspace directory.
|
|
38
39
|
- **See what it is waiting for**: in hook mode, a 🔴 waiting session carries the concrete pending item (the command to run, the question asked) — shown in the TUI and in the notification body.
|
|
39
40
|
- **Fits your status bar**: `ring --format oneline` prints a `🔴2 🟢1 🟡3` one-liner for tmux / SwiftBar / waybar; `--format json` feeds scripts.
|
|
@@ -128,7 +129,7 @@ Completes subcommands, flags, and `config set` keys; `ring focus` prompts for a
|
|
|
128
129
|
## Watch Mode
|
|
129
130
|
|
|
130
131
|
- With **Textual** (`[tui]` extra) in a real terminal: interactive TUI.
|
|
131
|
-
Use `↑/↓` to select, `Enter` / `Space` to jump, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
|
|
132
|
+
Use `↑/↓` to select, `Enter` / `Space` to jump, `p` to reply to a permission request in place, `n` to name a session, `a` to toggle ended sessions, `dd` to hide a session (it reappears automatically once it has new activity), `r` to refresh, and `q` to quit.
|
|
132
133
|
If you have vim muscle memory like I do, `j/k` move up/down and `g/G` jump to the first/last row.
|
|
133
134
|
When the selected row is 🔴 waiting, a line under the table shows **what it is concretely waiting for** (the command to run, the question asked; hook mode only).
|
|
134
135
|
- Otherwise: Rich polling; without Rich, plain text.
|
|
@@ -147,6 +148,30 @@ hands the request to the TUI so it selects that session; otherwise it focuses th
|
|
|
147
148
|
TTY matching is most accurate in hook mode. Without hooks, Codex falls back to zero-config matching:
|
|
148
149
|
one live Codex session per cwd can jump correctly; multiple live Codex sessions in the same cwd are shown conservatively to avoid focusing the wrong tab.
|
|
149
150
|
|
|
151
|
+
### Reply To Permission Requests In Place (`p`)
|
|
152
|
+
|
|
153
|
+
With the cursor on a 🔴 waiting row, press `p`. RiNG reads that session's screen, parses the
|
|
154
|
+
permission dialog (Claude Code's "Do you want to proceed?" box, including background-subagent
|
|
155
|
+
ones with a "from the … agent" header), and lists the numbered options verbatim in a modal.
|
|
156
|
+
After you pick one, RiNG **captures the screen again** to confirm the dialog is still there and
|
|
157
|
+
unchanged (it may have been answered while you were deciding), only then sends that single
|
|
158
|
+
digit, and re-checks that the dialog actually disappeared.
|
|
159
|
+
|
|
160
|
+
- **Sessions inside tmux**: reads the screen with `tmux capture-pane` and sends keys with
|
|
161
|
+
`tmux send-keys`.
|
|
162
|
+
- **Sessions in a plain iTerm2 tab on macOS** (no tmux): locates the matching iTerm2 session by
|
|
163
|
+
its `tty` via AppleScript, and both reads the screen and sends keys through `osascript`. The
|
|
164
|
+
first use triggers the macOS Automation permission prompt ("allow control of iTerm2") — allow
|
|
165
|
+
it once.
|
|
166
|
+
|
|
167
|
+
Safety first: if no recognizable dialog can be parsed (missing markers, numbering, or cursor),
|
|
168
|
+
RiNG only shows a toast and **never sends a key** — without the dialog, keystrokes would land in
|
|
169
|
+
the chat input box as text. If the dialog happens to vanish in the instant the digit is sent and
|
|
170
|
+
the digit lands in the input box, RiNG sends a Backspace to clean it up and warns you.
|
|
171
|
+
|
|
172
|
+
**Limits**: reading the screen requires a tmux pane coordinate, or (on macOS) an iTerm2 session
|
|
173
|
+
with a detectable tty; for other sessions, press `Enter` to jump over and reply there.
|
|
174
|
+
|
|
150
175
|
### Notifications
|
|
151
176
|
|
|
152
177
|
Default behavior: with hooks installed, the moment a session changes to 🔴 waiting, the hook beeps
|
|
@@ -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.11.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"
|
|
Binary file
|
|
@@ -653,3 +653,42 @@ msgstr "total wait"
|
|
|
653
653
|
|
|
654
654
|
msgid "⚠️ 其中 {n} 段還在 🔴 等你(計到現在為止)。"
|
|
655
655
|
msgstr "⚠️ {n} of these are still 🔴 waiting for you (counted up to now)."
|
|
656
|
+
|
|
657
|
+
msgid "回覆權限"
|
|
658
|
+
msgstr "reply perm"
|
|
659
|
+
|
|
660
|
+
msgid "回覆 {project} 的權限請求"
|
|
661
|
+
msgstr "Reply to the permission request from {project}"
|
|
662
|
+
|
|
663
|
+
msgid "Enter 或數字鍵送出、Esc 取消"
|
|
664
|
+
msgstr "Enter or a digit key sends, Esc cancels"
|
|
665
|
+
|
|
666
|
+
msgid "{project}:沒有 tmux 座標,且非 macOS 上的 iTerm2 session,無法就地回覆"
|
|
667
|
+
msgstr "{project}: no tmux coordinates, and not a macOS iTerm2 session either — cannot reply in place"
|
|
668
|
+
|
|
669
|
+
msgid "{project}:讀不到 {backend} 畫面"
|
|
670
|
+
msgstr "{project}: cannot read the {backend} screen"
|
|
671
|
+
|
|
672
|
+
msgid "{project}:畫面上沒有可辨識的權限對話框,未送出任何按鍵"
|
|
673
|
+
msgstr "{project}: no recognizable permission dialog on screen; no key was sent"
|
|
674
|
+
|
|
675
|
+
msgid "→ {project}:已回覆權限({option})"
|
|
676
|
+
msgstr "→ {project}: permission answered ({option})"
|
|
677
|
+
|
|
678
|
+
msgid "{project}:權限對話框已不在,未送出任何按鍵"
|
|
679
|
+
msgstr "{project}: the permission dialog is gone; no key was sent"
|
|
680
|
+
|
|
681
|
+
msgid "{project}:對話框內容已變化,未送出;請再按一次 p"
|
|
682
|
+
msgstr "{project}: the dialog changed; nothing sent — press p again"
|
|
683
|
+
|
|
684
|
+
msgid "{project}:tmux send-keys 失敗,沒能回覆"
|
|
685
|
+
msgstr "{project}: tmux send-keys failed; could not reply"
|
|
686
|
+
|
|
687
|
+
msgid "{project}:已送出但對話框還在,請跳過去確認"
|
|
688
|
+
msgstr "{project}: key sent but the dialog is still there — jump over to check"
|
|
689
|
+
|
|
690
|
+
msgid "{project}:對話框已不在,數字落進輸入框,已補 Backspace 清掉"
|
|
691
|
+
msgstr "{project}: the dialog was gone and the digit landed in the input box — sent Backspace to clean it up"
|
|
692
|
+
|
|
693
|
+
msgid "{project}:已送出但拿不到畫面驗證,請跳過去確認"
|
|
694
|
+
msgstr "{project}: key sent but the screen could not be re-read to verify — jump over to check"
|
|
@@ -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-12 09:22+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"
|
|
@@ -52,7 +52,7 @@ msgstr[1] ""
|
|
|
52
52
|
msgid "🎤 RiNG — {sess} · {proc}"
|
|
53
53
|
msgstr ""
|
|
54
54
|
|
|
55
|
-
#: src/ring/cli.py:163 src/ring/cli.py:216 src/ring/tui.py:
|
|
55
|
+
#: src/ring/cli.py:163 src/ring/cli.py:216 src/ring/tui.py:274
|
|
56
56
|
msgid "圖例"
|
|
57
57
|
msgstr ""
|
|
58
58
|
|
|
@@ -63,32 +63,32 @@ msgstr ""
|
|
|
63
63
|
#: src/ring/cli.py:184 src/ring/cli.py:338 src/ring/commands/doctor.py:31
|
|
64
64
|
#: src/ring/commands/doctor.py:68 src/ring/commands/doctor.py:70
|
|
65
65
|
#: src/ring/commands/doctor.py:72 src/ring/commands/doctor.py:134
|
|
66
|
-
#: src/ring/tui.py:
|
|
66
|
+
#: src/ring/tui.py:229
|
|
67
67
|
msgid "狀態"
|
|
68
68
|
msgstr ""
|
|
69
69
|
|
|
70
|
-
#: src/ring/cli.py:186 src/ring/cli.py:235 src/ring/tui.py:
|
|
70
|
+
#: src/ring/cli.py:186 src/ring/cli.py:235 src/ring/tui.py:231
|
|
71
71
|
msgid "工具"
|
|
72
72
|
msgstr ""
|
|
73
73
|
|
|
74
74
|
#: src/ring/cli.py:187 src/ring/cli.py:235 src/ring/commands/stats.py:60
|
|
75
|
-
#: src/ring/tui.py:
|
|
75
|
+
#: src/ring/tui.py:232
|
|
76
76
|
msgid "專案"
|
|
77
77
|
msgstr ""
|
|
78
78
|
|
|
79
|
-
#: src/ring/cli.py:188 src/ring/cli.py:235 src/ring/tui.py:
|
|
79
|
+
#: src/ring/cli.py:188 src/ring/cli.py:235 src/ring/tui.py:232
|
|
80
80
|
msgid "進度"
|
|
81
81
|
msgstr ""
|
|
82
82
|
|
|
83
|
-
#: src/ring/cli.py:189 src/ring/cli.py:235 src/ring/tui.py:
|
|
83
|
+
#: src/ring/cli.py:189 src/ring/cli.py:235 src/ring/tui.py:232
|
|
84
84
|
msgid "閒置"
|
|
85
85
|
msgstr ""
|
|
86
86
|
|
|
87
|
-
#: src/ring/cli.py:190 src/ring/cli.py:235 src/ring/tui.py:
|
|
87
|
+
#: src/ring/cli.py:190 src/ring/cli.py:235 src/ring/tui.py:232
|
|
88
88
|
msgid "去哪"
|
|
89
89
|
msgstr ""
|
|
90
90
|
|
|
91
|
-
#: src/ring/cli.py:192 src/ring/cli.py:235 src/ring/tui.py:
|
|
91
|
+
#: src/ring/cli.py:192 src/ring/cli.py:235 src/ring/tui.py:232
|
|
92
92
|
msgid "動作"
|
|
93
93
|
msgstr ""
|
|
94
94
|
|
|
@@ -305,41 +305,41 @@ msgstr ""
|
|
|
305
305
|
msgid "重開 Codex session、並在它詢問時「信任」這個 hook,🔴 等你狀態才會精準起來(Codex 不會執行未信任的 hook)。"
|
|
306
306
|
msgstr ""
|
|
307
307
|
|
|
308
|
-
#: src/ring/hook.py:
|
|
308
|
+
#: src/ring/hook.py:438
|
|
309
309
|
#, python-brace-format
|
|
310
310
|
msgid "⚠️ 偵測到其他工具也掛在 {events}:{cmds}"
|
|
311
311
|
msgstr ""
|
|
312
312
|
|
|
313
|
-
#: src/ring/hook.py:
|
|
313
|
+
#: src/ring/hook.py:439
|
|
314
314
|
msgid "它們會跟 RiNG 的通知重複觸發;要完全改用 RiNG,建議移除它們。"
|
|
315
315
|
msgstr ""
|
|
316
316
|
|
|
317
|
-
#: src/ring/hook.py:
|
|
317
|
+
#: src/ring/hook.py:487 src/ring/hook.py:563
|
|
318
318
|
#, python-brace-format
|
|
319
319
|
msgid "⚠️ {path} 不是合法 JSON,先處理它再來。"
|
|
320
320
|
msgstr ""
|
|
321
321
|
|
|
322
|
-
#: src/ring/hook.py:
|
|
322
|
+
#: src/ring/hook.py:525
|
|
323
323
|
#, python-brace-format
|
|
324
324
|
msgid "✅ RiNG hooks 已經裝過了,沒有變更。({path})"
|
|
325
325
|
msgstr ""
|
|
326
326
|
|
|
327
|
-
#: src/ring/hook.py:
|
|
327
|
+
#: src/ring/hook.py:538
|
|
328
328
|
#, python-brace-format
|
|
329
329
|
msgid "✅ 已註冊 RiNG hooks({events})到 {path}"
|
|
330
330
|
msgstr ""
|
|
331
331
|
|
|
332
|
-
#: src/ring/hook.py:
|
|
332
|
+
#: src/ring/hook.py:557
|
|
333
333
|
#, python-brace-format
|
|
334
334
|
msgid "ℹ️ {path} 不存在,沒有可移除的 hook。"
|
|
335
335
|
msgstr ""
|
|
336
336
|
|
|
337
|
-
#: src/ring/hook.py:
|
|
337
|
+
#: src/ring/hook.py:584
|
|
338
338
|
#, python-brace-format
|
|
339
339
|
msgid "ℹ️ 沒有找到 RiNG hook 條目,無需移除。({path})"
|
|
340
340
|
msgstr ""
|
|
341
341
|
|
|
342
|
-
#: src/ring/hook.py:
|
|
342
|
+
#: src/ring/hook.py:588
|
|
343
343
|
#, python-brace-format
|
|
344
344
|
msgid "✅ 已移除 RiNG hooks({events})從 {path}"
|
|
345
345
|
msgstr ""
|
|
@@ -349,103 +349,167 @@ msgstr ""
|
|
|
349
349
|
msgid "⚠️ ring plugin '{name}' 載入失敗:{error}"
|
|
350
350
|
msgstr ""
|
|
351
351
|
|
|
352
|
-
#: src/ring/tui.py:
|
|
352
|
+
#: src/ring/tui.py:91
|
|
353
353
|
#, python-brace-format
|
|
354
354
|
msgid "為 {project} 命名(Enter 存、Esc 取消、清空移除)"
|
|
355
355
|
msgstr ""
|
|
356
356
|
|
|
357
|
-
#: src/ring/tui.py:
|
|
357
|
+
#: src/ring/tui.py:92
|
|
358
358
|
msgid "這個 session 在做什麼…"
|
|
359
359
|
msgstr ""
|
|
360
360
|
|
|
361
|
-
#: src/ring/tui.py:
|
|
361
|
+
#: src/ring/tui.py:136
|
|
362
|
+
#, python-brace-format
|
|
363
|
+
msgid "回覆 {project} 的權限請求"
|
|
364
|
+
msgstr ""
|
|
365
|
+
|
|
366
|
+
#: src/ring/tui.py:141
|
|
367
|
+
msgid "Enter 或數字鍵送出、Esc 取消"
|
|
368
|
+
msgstr ""
|
|
369
|
+
|
|
370
|
+
#: src/ring/tui.py:164
|
|
362
371
|
msgid "離場"
|
|
363
372
|
msgstr ""
|
|
364
373
|
|
|
365
|
-
#: src/ring/tui.py:
|
|
374
|
+
#: src/ring/tui.py:165
|
|
366
375
|
msgid "刷新"
|
|
367
376
|
msgstr ""
|
|
368
377
|
|
|
369
|
-
#: src/ring/tui.py:
|
|
378
|
+
#: src/ring/tui.py:166
|
|
370
379
|
msgid "含已離場"
|
|
371
380
|
msgstr ""
|
|
372
381
|
|
|
373
|
-
#: src/ring/tui.py:
|
|
382
|
+
#: src/ring/tui.py:167
|
|
374
383
|
msgid "命名"
|
|
375
384
|
msgstr ""
|
|
376
385
|
|
|
377
|
-
#: src/ring/tui.py:
|
|
386
|
+
#: src/ring/tui.py:168
|
|
378
387
|
msgid "隱藏"
|
|
379
388
|
msgstr ""
|
|
380
389
|
|
|
381
|
-
#: src/ring/tui.py:
|
|
390
|
+
#: src/ring/tui.py:169
|
|
382
391
|
msgid "最久等待"
|
|
383
392
|
msgstr ""
|
|
384
393
|
|
|
385
|
-
#: src/ring/tui.py:
|
|
394
|
+
#: src/ring/tui.py:170
|
|
395
|
+
msgid "回覆權限"
|
|
396
|
+
msgstr ""
|
|
397
|
+
|
|
398
|
+
#: src/ring/tui.py:171 src/ring/tui.py:172
|
|
386
399
|
msgid "跳過去"
|
|
387
400
|
msgstr ""
|
|
388
401
|
|
|
389
|
-
#: src/ring/tui.py:
|
|
402
|
+
#: src/ring/tui.py:250
|
|
390
403
|
msgid "💡 同專案開了多個 session,裝 hook 跳轉才精準:ring install-hooks"
|
|
391
404
|
msgstr ""
|
|
392
405
|
|
|
393
|
-
#: src/ring/tui.py:
|
|
406
|
+
#: src/ring/tui.py:252
|
|
394
407
|
msgid "↑/↓ 選一列,Enter 或 Space 跳到那個 session 的終端"
|
|
395
408
|
msgstr ""
|
|
396
409
|
|
|
397
|
-
#: src/ring/tui.py:
|
|
410
|
+
#: src/ring/tui.py:288
|
|
398
411
|
#, python-brace-format
|
|
399
412
|
msgid "🔔 {names} 在等你回話"
|
|
400
413
|
msgstr ""
|
|
401
414
|
|
|
402
|
-
#: src/ring/tui.py:
|
|
415
|
+
#: src/ring/tui.py:329
|
|
403
416
|
#, python-brace-format
|
|
404
417
|
msgid "→ 已跳到 {project}(來自通知)"
|
|
405
418
|
msgstr ""
|
|
406
419
|
|
|
407
|
-
#: src/ring/tui.py:
|
|
420
|
+
#: src/ring/tui.py:334
|
|
408
421
|
msgid "那個 session 已不在場"
|
|
409
422
|
msgstr ""
|
|
410
423
|
|
|
411
|
-
#: src/ring/tui.py:
|
|
424
|
+
#: src/ring/tui.py:393
|
|
412
425
|
msgid " ⚠ hook 可能失效:來源檔有更新但沒有 heartbeat"
|
|
413
426
|
msgstr ""
|
|
414
427
|
|
|
415
|
-
#: src/ring/tui.py:
|
|
428
|
+
#: src/ring/tui.py:422 src/ring/tui.py:446 src/ring/tui.py:479
|
|
429
|
+
#: src/ring/tui.py:544
|
|
416
430
|
msgid "(沒有選到 session)"
|
|
417
431
|
msgstr ""
|
|
418
432
|
|
|
419
|
-
#: src/ring/tui.py:
|
|
433
|
+
#: src/ring/tui.py:453
|
|
420
434
|
#, python-brace-format
|
|
421
435
|
msgid "→ {project}({where})"
|
|
422
436
|
msgstr ""
|
|
423
437
|
|
|
424
|
-
#: src/ring/tui.py:
|
|
438
|
+
#: src/ring/tui.py:455
|
|
425
439
|
#, python-brace-format
|
|
426
440
|
msgid "{project}:{msg}"
|
|
427
441
|
msgstr ""
|
|
428
442
|
|
|
429
|
-
#: src/ring/tui.py:
|
|
443
|
+
#: src/ring/tui.py:463
|
|
430
444
|
msgid "(沒有正在等待的 session)"
|
|
431
445
|
msgstr ""
|
|
432
446
|
|
|
433
|
-
#: src/ring/tui.py:
|
|
447
|
+
#: src/ring/tui.py:486
|
|
448
|
+
#, python-brace-format
|
|
449
|
+
msgid "{project}:沒有 tmux 座標,且非 macOS 上的 iTerm2 session,無法就地回覆"
|
|
450
|
+
msgstr ""
|
|
451
|
+
|
|
452
|
+
#: src/ring/tui.py:493
|
|
453
|
+
#, python-brace-format
|
|
454
|
+
msgid "{project}:讀不到 {backend} 畫面"
|
|
455
|
+
msgstr ""
|
|
456
|
+
|
|
457
|
+
#: src/ring/tui.py:497
|
|
458
|
+
#, python-brace-format
|
|
459
|
+
msgid "{project}:畫面上沒有可辨識的權限對話框,未送出任何按鍵"
|
|
460
|
+
msgstr ""
|
|
461
|
+
|
|
462
|
+
#: src/ring/tui.py:526
|
|
463
|
+
#, python-brace-format
|
|
464
|
+
msgid "→ {project}:已回覆權限({option})"
|
|
465
|
+
msgstr ""
|
|
466
|
+
|
|
467
|
+
#: src/ring/tui.py:530
|
|
468
|
+
#, python-brace-format
|
|
469
|
+
msgid "{project}:權限對話框已不在,未送出任何按鍵"
|
|
470
|
+
msgstr ""
|
|
471
|
+
|
|
472
|
+
#: src/ring/tui.py:531
|
|
473
|
+
#, python-brace-format
|
|
474
|
+
msgid "{project}:對話框內容已變化,未送出;請再按一次 p"
|
|
475
|
+
msgstr ""
|
|
476
|
+
|
|
477
|
+
#: src/ring/tui.py:532
|
|
478
|
+
#, python-brace-format
|
|
479
|
+
msgid "{project}:tmux send-keys 失敗,沒能回覆"
|
|
480
|
+
msgstr ""
|
|
481
|
+
|
|
482
|
+
#: src/ring/tui.py:533
|
|
483
|
+
#, python-brace-format
|
|
484
|
+
msgid "{project}:已送出但對話框還在,請跳過去確認"
|
|
485
|
+
msgstr ""
|
|
486
|
+
|
|
487
|
+
#: src/ring/tui.py:535
|
|
488
|
+
#, python-brace-format
|
|
489
|
+
msgid "{project}:對話框已不在,數字落進輸入框,已補 Backspace 清掉"
|
|
490
|
+
msgstr ""
|
|
491
|
+
|
|
492
|
+
#: src/ring/tui.py:537
|
|
493
|
+
#, python-brace-format
|
|
494
|
+
msgid "{project}:已送出但拿不到畫面驗證,請跳過去確認"
|
|
495
|
+
msgstr ""
|
|
496
|
+
|
|
497
|
+
#: src/ring/tui.py:552
|
|
434
498
|
#, python-brace-format
|
|
435
499
|
msgid "再按一次 d 隱藏 {project}(有新活動會自動重新出現)"
|
|
436
500
|
msgstr ""
|
|
437
501
|
|
|
438
|
-
#: src/ring/tui.py:
|
|
502
|
+
#: src/ring/tui.py:557
|
|
439
503
|
#, python-brace-format
|
|
440
504
|
msgid "{project} 是仍在執行的 process,無法隱藏;請跳過去結束它"
|
|
441
505
|
msgstr ""
|
|
442
506
|
|
|
443
|
-
#: src/ring/tui.py:
|
|
507
|
+
#: src/ring/tui.py:565
|
|
444
508
|
#, python-brace-format
|
|
445
509
|
msgid "已隱藏 {project},並清掉 RiNG 狀態;有新活動會自動重新出現"
|
|
446
510
|
msgstr ""
|
|
447
511
|
|
|
448
|
-
#: src/ring/tui.py:
|
|
512
|
+
#: src/ring/tui.py:567
|
|
449
513
|
#, python-brace-format
|
|
450
514
|
msgid "已隱藏 {project}(沒有 RiNG registry 可清;有新活動會自動重新出現)"
|
|
451
515
|
msgstr ""
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""面板內回覆權限請求——讀終端上的 Claude Code 權限對話框、代你按下選項。
|
|
2
|
+
|
|
3
|
+
流程:``backend.capture()`` 抓畫面 → ``parse_permission_dialog()`` 解析出編號選項 →
|
|
4
|
+
TUI 開浮層讓你選 → ``send_permission_reply()`` 送出前再抓一次確認對話框還在且沒變
|
|
5
|
+
(防 race),才送單一數字(**不帶 Enter**——對話框上按數字即選中;帶了 Enter 反而會
|
|
6
|
+
在對話框剛好消失時把字送進聊天輸入框)。
|
|
7
|
+
|
|
8
|
+
安全底線:任何解析不確定(抓不到對話框標記、選項編號不連續、沒有游標)一律回
|
|
9
|
+
``None`` / 不送鍵。誤送偵測:對話框不在時數字會落進聊天輸入框(``❯ 2``),此時補送
|
|
10
|
+
Backspace 清掉。
|
|
11
|
+
|
|
12
|
+
``send_permission_reply()`` 的流程本體只有一份,實際怎麼抓畫面/怎麼送鍵由
|
|
13
|
+
``PermissionBackend``(tmux / iTerm2)決定——照 ``focus/base.py`` 的 Protocol 形狀走。
|
|
14
|
+
tmux 互動集中在這裡(照 ``focus/tmux.py`` 的形狀走 subprocess),iTerm2 互動走
|
|
15
|
+
``ring.osascript``(照 ``focus/applescript.py`` 的形狀)。解析器是純函式,測試直接餵
|
|
16
|
+
PoC 抓下來的真實畫面(``tests/fixtures/permission/``)。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import Protocol
|
|
29
|
+
|
|
30
|
+
from ring.osascript import osascript
|
|
31
|
+
from ring.registry import Session
|
|
32
|
+
|
|
33
|
+
# 對話框 footer:「 Esc to cancel · Tab to amend · ctrl+e to explain」。
|
|
34
|
+
# 注意大小寫——工作中狀態列的「esc to interrupt」是小寫 e,不會誤中。
|
|
35
|
+
_FOOTER_RE = re.compile(r"\bEsc to cancel\b")
|
|
36
|
+
# 編號選項列:「 ❯ 1. Yes」(游標)或「 2. Yes, and …」。
|
|
37
|
+
_OPTION_RE = re.compile(r"^\s*(?:❯\s*)?(\d)\.\s+(\S.*?)\s*$")
|
|
38
|
+
# 對話框標題列的 subagent 標頭:「Bash command · from the general-purpose agent」。
|
|
39
|
+
_AGENT_RE = re.compile(r"·\s*from the (.+?) agent\b")
|
|
40
|
+
# 對話框上緣的水平分隔線(整列 ─)。
|
|
41
|
+
_SEPARATOR_RE = re.compile(r"^\s*─{10,}\s*$")
|
|
42
|
+
# 聊天輸入框的提示列:「❯ <內容>」。誤送時數字會出現在這裡。
|
|
43
|
+
_INPUT_LINE_RE = re.compile(r"^\s*❯\s*(.+?)\s*$")
|
|
44
|
+
|
|
45
|
+
# 送出數字後等 UI 反應的秒數,之後再抓一次畫面驗證。
|
|
46
|
+
_VERIFY_DELAY = 0.4
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PermissionDialog:
|
|
51
|
+
"""解析出來的權限對話框。``options`` 是 (編號, 原文) 的序列,編號從 1 連續。"""
|
|
52
|
+
|
|
53
|
+
options: tuple[tuple[int, str], ...]
|
|
54
|
+
question: str = "" # e.g. "Do you want to proceed?"
|
|
55
|
+
title: str = "" # e.g. "Bash command · from the general-purpose agent"
|
|
56
|
+
agent: str = "" # subagent 名稱(標題帶 "from the … agent" 才有),e.g. "general-purpose"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ReplyOutcome(Enum):
|
|
60
|
+
"""``send_permission_reply()`` 的結果。只有 OK / MISFIRE 真的送過鍵。"""
|
|
61
|
+
|
|
62
|
+
OK = "ok" # 送出後對話框消失(或換成下一個請求)→ 成功
|
|
63
|
+
NO_DIALOG = "no_dialog" # 送出前的確認抓不到對話框 → 沒送
|
|
64
|
+
CHANGED = "changed" # 送出前的確認發現對話框變了 → 沒送
|
|
65
|
+
SEND_FAILED = "send_failed" # tmux send-keys 失敗
|
|
66
|
+
STILL_PRESENT = "still_present" # 送了,但同一個對話框還在 → 請跳過去確認
|
|
67
|
+
MISFIRE = "misfire" # 送了,數字落進聊天輸入框;已補 Backspace 清掉
|
|
68
|
+
UNVERIFIED = "unverified" # 送了,但驗證用的 capture 失敗 → 請跳過去確認
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# 純解析(不碰 tmux)
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_permission_dialog(screen: str) -> PermissionDialog | None:
|
|
77
|
+
"""從 capture-pane 的畫面解析權限對話框;任何標記缺失就回 ``None``(絕不猜)。
|
|
78
|
+
|
|
79
|
+
由下往上找,要求四個標記同時成立:
|
|
80
|
+
1. footer 列(``Esc to cancel``)
|
|
81
|
+
2. footer 上方是連續的編號選項列,編號從 1 起連續、至少 2 個
|
|
82
|
+
3. 至少一個選項列帶游標 ``❯``
|
|
83
|
+
4. 選項上方第一個非空白列是問句(以 ``?`` 結尾,如 ``Do you want to proceed?``)
|
|
84
|
+
"""
|
|
85
|
+
lines = screen.splitlines()
|
|
86
|
+
footer_idx = next((i for i in range(len(lines) - 1, -1, -1) if _FOOTER_RE.search(lines[i])), None)
|
|
87
|
+
if footer_idx is None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# footer 上方跳過空白列,往上收集連續的編號選項列。
|
|
91
|
+
i = footer_idx - 1
|
|
92
|
+
while i >= 0 and not lines[i].strip():
|
|
93
|
+
i -= 1
|
|
94
|
+
collected: list[tuple[int, str]] = []
|
|
95
|
+
saw_cursor = False
|
|
96
|
+
while i >= 0:
|
|
97
|
+
m = _OPTION_RE.match(lines[i])
|
|
98
|
+
if m is None:
|
|
99
|
+
break
|
|
100
|
+
collected.append((int(m.group(1)), m.group(2)))
|
|
101
|
+
if "❯" in lines[i]:
|
|
102
|
+
saw_cursor = True
|
|
103
|
+
i -= 1
|
|
104
|
+
options = tuple(reversed(collected))
|
|
105
|
+
if len(options) < 2 or not saw_cursor:
|
|
106
|
+
return None
|
|
107
|
+
if [n for n, _text in options] != list(range(1, len(options) + 1)):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
# 問句:選項上方第一個非空白列,必須以 ? 結尾。
|
|
111
|
+
while i >= 0 and not lines[i].strip():
|
|
112
|
+
i -= 1
|
|
113
|
+
if i < 0 or not lines[i].rstrip().endswith("?"):
|
|
114
|
+
return None
|
|
115
|
+
question = lines[i].strip()
|
|
116
|
+
|
|
117
|
+
# 標題:往上找到對話框上緣的分隔線,其下第一個非空白列
|
|
118
|
+
# (e.g. "Bash command · from the general-purpose agent")。找不到就留空,不影響判定。
|
|
119
|
+
title = ""
|
|
120
|
+
sep_idx = next((j for j in range(i - 1, -1, -1) if _SEPARATOR_RE.match(lines[j])), None)
|
|
121
|
+
if sep_idx is not None:
|
|
122
|
+
title = next((lines[j].strip() for j in range(sep_idx + 1, i) if lines[j].strip()), "")
|
|
123
|
+
agent_match = _AGENT_RE.search(title)
|
|
124
|
+
agent = agent_match.group(1) if agent_match else ""
|
|
125
|
+
return PermissionDialog(options=options, question=question, title=title, agent=agent)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def digit_in_input_line(screen: str, digit: str) -> bool:
|
|
129
|
+
"""對話框不在時送出的數字會落進聊天輸入框,變成一行「``❯ 2``」——偵測這種誤送。"""
|
|
130
|
+
for line in screen.splitlines():
|
|
131
|
+
m = _INPUT_LINE_RE.match(line)
|
|
132
|
+
if m is not None and m.group(1) == digit:
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# backend 協定——capture/send 可替換,流程本體(下面 send_permission_reply)只有一份
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class PermissionBackend(Protocol):
|
|
143
|
+
"""一個終端的「抓畫面 / 送鍵」能力。照 ``focus/base.py`` 的 Protocol 形狀走。
|
|
144
|
+
|
|
145
|
+
``name`` 宣告成 read-only property:實作是 frozen dataclass,欄位不可寫,
|
|
146
|
+
宣告成一般屬性會被 mypy 判定不相容。
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def name(self) -> str: ...
|
|
151
|
+
|
|
152
|
+
def capture(self) -> str | None: ...
|
|
153
|
+
def send_digit(self, digit: str) -> bool: ...
|
|
154
|
+
def send_backspace(self) -> bool: ...
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# tmux 互動(全部走 subprocess,測試一律 mock)
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _run_tmux(args: list[str]) -> subprocess.CompletedProcess[str] | None:
|
|
163
|
+
if not shutil.which("tmux"):
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
return subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=3)
|
|
167
|
+
except (OSError, subprocess.SubprocessError):
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def capture_pane(target: str) -> str | None:
|
|
172
|
+
"""抓 pane 目前的可見畫面;tmux 不在 / target 無效 → ``None``。"""
|
|
173
|
+
result = _run_tmux(["capture-pane", "-p", "-t", target])
|
|
174
|
+
if result is None or result.returncode != 0:
|
|
175
|
+
return None
|
|
176
|
+
return result.stdout
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def send_key(target: str, key: str) -> bool:
|
|
180
|
+
"""對 pane 送一個鍵(tmux key 名,如 ``2`` / ``BSpace``)。"""
|
|
181
|
+
result = _run_tmux(["send-keys", "-t", target, key])
|
|
182
|
+
return result is not None and result.returncode == 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class TmuxBackend:
|
|
187
|
+
"""tmux pane 的 backend:``capture_pane`` / ``send_key`` 包一層給流程本體用。"""
|
|
188
|
+
|
|
189
|
+
target: str
|
|
190
|
+
name: str = "tmux"
|
|
191
|
+
|
|
192
|
+
def capture(self) -> str | None:
|
|
193
|
+
return capture_pane(self.target)
|
|
194
|
+
|
|
195
|
+
def send_digit(self, digit: str) -> bool:
|
|
196
|
+
return send_key(self.target, digit)
|
|
197
|
+
|
|
198
|
+
def send_backspace(self) -> bool:
|
|
199
|
+
return send_key(self.target, "BSpace")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# iTerm2 互動(走 osascript,測試一律 mock)
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
# 依 tty 找到對應 session 後執行 {action};找不到就回 sentinel(真實畫面內容不可能剛好
|
|
207
|
+
# 撞上這個字串)。tty 定位邏輯與 ``focus/iterm2.py`` 的 focuser 同形,各自獨立維護——
|
|
208
|
+
# 一個是「聚焦分頁」,一個是「抓畫面/送鍵」,職責不同,硬併會讓兩邊都難改。
|
|
209
|
+
_ITERM_NO_SESSION = "__ring_no_session__"
|
|
210
|
+
_ITERM_SCRIPT = """
|
|
211
|
+
if application "iTerm2" is running then
|
|
212
|
+
tell application "iTerm2"
|
|
213
|
+
repeat with w in windows
|
|
214
|
+
repeat with t in tabs of w
|
|
215
|
+
repeat with s in sessions of t
|
|
216
|
+
if tty of s is "{tty}" then
|
|
217
|
+
{action}
|
|
218
|
+
end if
|
|
219
|
+
end repeat
|
|
220
|
+
end repeat
|
|
221
|
+
end repeat
|
|
222
|
+
end tell
|
|
223
|
+
end if
|
|
224
|
+
return "{sentinel}"
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _iterm_run(tty: str, action: str) -> tuple[int, str, str] | None:
|
|
229
|
+
if not tty or not shutil.which("osascript"):
|
|
230
|
+
return None
|
|
231
|
+
return osascript(_ITERM_SCRIPT.format(tty=tty, action=action, sentinel=_ITERM_NO_SESSION))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def iterm_capture(tty: str) -> str | None:
|
|
235
|
+
"""抓 iTerm2 session 目前的可見畫面(``contents of session``);找不到 → ``None``。"""
|
|
236
|
+
result = _iterm_run(tty, "return contents of s")
|
|
237
|
+
if result is None:
|
|
238
|
+
return None
|
|
239
|
+
rc, out, _err = result
|
|
240
|
+
if rc != 0 or out == _ITERM_NO_SESSION:
|
|
241
|
+
return None
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _iterm_write(tty: str, payload: str) -> bool:
|
|
246
|
+
result = _iterm_run(tty, f'tell s to write text {payload} newline NO\n return "ok"')
|
|
247
|
+
return result is not None and result[0] == 0 and result[1] == "ok"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def iterm_send_digit(tty: str, digit: str) -> bool:
|
|
251
|
+
"""對 iTerm2 session 送一個數字——單一數字直接觸發選項,不帶 Enter。"""
|
|
252
|
+
return _iterm_write(tty, f'"{digit}"')
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def iterm_send_backspace(tty: str) -> bool:
|
|
256
|
+
"""對 iTerm2 session 送 Backspace(誤送補救用)。"""
|
|
257
|
+
return _iterm_write(tty, "(ASCII character 8)")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True)
|
|
261
|
+
class ITermBackend:
|
|
262
|
+
"""iTerm2 session 的 backend:依 tty 定位 session,抓畫面/送鍵都透過 osascript。"""
|
|
263
|
+
|
|
264
|
+
tty: str
|
|
265
|
+
name: str = "iTerm2"
|
|
266
|
+
|
|
267
|
+
def capture(self) -> str | None:
|
|
268
|
+
return iterm_capture(self.tty)
|
|
269
|
+
|
|
270
|
+
def send_digit(self, digit: str) -> bool:
|
|
271
|
+
return iterm_send_digit(self.tty, digit)
|
|
272
|
+
|
|
273
|
+
def send_backspace(self) -> bool:
|
|
274
|
+
return iterm_send_backspace(self.tty)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def select_backend(session: Session) -> PermissionBackend | None:
|
|
278
|
+
"""依 session 現有座標選 backend:tmux 座標優先,其次 macOS 上有 tty 就用 iTerm2。
|
|
279
|
+
|
|
280
|
+
都沒有 → ``None``,呼叫端走既有的 toast(不送鍵)路徑。
|
|
281
|
+
"""
|
|
282
|
+
target = session.tmux_pane or session.tmux_target
|
|
283
|
+
if target:
|
|
284
|
+
return TmuxBackend(target)
|
|
285
|
+
if session.tty and sys.platform == "darwin":
|
|
286
|
+
return ITermBackend(session.tty)
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# 送鍵流程本體——backend 換 tmux / iTerm2,流程只有這一份
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def send_permission_reply(
|
|
296
|
+
backend: PermissionBackend,
|
|
297
|
+
expected: PermissionDialog,
|
|
298
|
+
number: int,
|
|
299
|
+
*,
|
|
300
|
+
delay: float = _VERIFY_DELAY,
|
|
301
|
+
) -> ReplyOutcome:
|
|
302
|
+
"""把選定的選項編號送進終端上的權限對話框,送出前後都驗證。
|
|
303
|
+
|
|
304
|
+
1. 再 capture 一次:對話框不在 → ``NO_DIALOG``;跟 ``expected`` 不同 → ``CHANGED``。
|
|
305
|
+
兩者都**不送鍵**(使用者在浮層裡想的期間,對話框可能已被本人回掉或換內容)。
|
|
306
|
+
2. 送單一數字(不帶 Enter)。
|
|
307
|
+
3. 等 ``delay`` 秒再 capture 驗證:對話框消失 → ``OK``;數字落進輸入框 → 補
|
|
308
|
+
Backspace、``MISFIRE``;同一個對話框還在 → ``STILL_PRESENT``。
|
|
309
|
+
"""
|
|
310
|
+
if not any(number == n for n, _text in expected.options):
|
|
311
|
+
return ReplyOutcome.CHANGED # 編號不在選項裡,視同對話框對不上,不送
|
|
312
|
+
screen = backend.capture()
|
|
313
|
+
if screen is None:
|
|
314
|
+
return ReplyOutcome.NO_DIALOG
|
|
315
|
+
current = parse_permission_dialog(screen)
|
|
316
|
+
if current is None:
|
|
317
|
+
return ReplyOutcome.NO_DIALOG
|
|
318
|
+
if current != expected:
|
|
319
|
+
return ReplyOutcome.CHANGED
|
|
320
|
+
if not backend.send_digit(str(number)):
|
|
321
|
+
return ReplyOutcome.SEND_FAILED
|
|
322
|
+
time.sleep(delay)
|
|
323
|
+
after = backend.capture()
|
|
324
|
+
if after is None:
|
|
325
|
+
return ReplyOutcome.UNVERIFIED
|
|
326
|
+
after_dialog = parse_permission_dialog(after)
|
|
327
|
+
if after_dialog is None:
|
|
328
|
+
if digit_in_input_line(after, str(number)):
|
|
329
|
+
backend.send_backspace()
|
|
330
|
+
return ReplyOutcome.MISFIRE
|
|
331
|
+
return ReplyOutcome.OK
|
|
332
|
+
if after_dialog == expected:
|
|
333
|
+
return ReplyOutcome.STILL_PRESENT
|
|
334
|
+
return ReplyOutcome.OK # 對話框換成下一個請求 → 原請求已被回覆
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
需要 textual(``pip install 'ring[tui]'``)。沒裝時 CLI 會自動退回 Rich poll。
|
|
4
4
|
|
|
5
5
|
鍵:↑/↓(或 vim 的 j/k、g/G 跳頭尾)選 session、Enter/Space 跳到它所在的終端、
|
|
6
|
-
a 切換是否顯示已離場、dd 隱藏 session
|
|
6
|
+
p 就地回覆權限請求(tmux 內的 session)、a 切換是否顯示已離場、dd 隱藏 session
|
|
7
|
+
(有新活動會自動重新出現)、r 刷新、q 離場。
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -13,12 +14,14 @@ import time
|
|
|
13
14
|
from typing import ClassVar
|
|
14
15
|
|
|
15
16
|
from rich.text import Text
|
|
17
|
+
from textual import events
|
|
16
18
|
from textual.app import App, ComposeResult
|
|
17
19
|
from textual.binding import Binding, BindingType
|
|
18
20
|
from textual.containers import Vertical
|
|
19
21
|
from textual.screen import ModalScreen
|
|
20
|
-
from textual.widgets import DataTable, Footer, Header, Input, Static
|
|
22
|
+
from textual.widgets import DataTable, Footer, Header, Input, OptionList, Static
|
|
21
23
|
|
|
24
|
+
from ring import permission
|
|
22
25
|
from ring.cli import (
|
|
23
26
|
_LOC_MAX,
|
|
24
27
|
_STATUS_STYLE,
|
|
@@ -98,6 +101,61 @@ class _NameModal(ModalScreen[str | None]):
|
|
|
98
101
|
self.dismiss(None)
|
|
99
102
|
|
|
100
103
|
|
|
104
|
+
class _PermissionModal(ModalScreen[int | None]):
|
|
105
|
+
"""就地回覆權限請求的浮層:列出對話框原文選項,↑/↓+Enter 或直接按數字鍵選、Esc 取消。
|
|
106
|
+
|
|
107
|
+
dismiss 回傳:選項編號(int)或 ``None``(取消,不送任何鍵)。
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
DEFAULT_CSS = """
|
|
111
|
+
_PermissionModal {
|
|
112
|
+
align: center middle;
|
|
113
|
+
}
|
|
114
|
+
_PermissionModal #perm-box {
|
|
115
|
+
width: 90;
|
|
116
|
+
max-width: 100%;
|
|
117
|
+
height: auto;
|
|
118
|
+
padding: 1 2;
|
|
119
|
+
background: $panel;
|
|
120
|
+
border: round $accent;
|
|
121
|
+
}
|
|
122
|
+
_PermissionModal OptionList {
|
|
123
|
+
height: auto;
|
|
124
|
+
background: $panel;
|
|
125
|
+
border: none;
|
|
126
|
+
}
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, project: str, dialog: permission.PermissionDialog) -> None:
|
|
130
|
+
super().__init__()
|
|
131
|
+
self._project = project
|
|
132
|
+
self._dialog = dialog
|
|
133
|
+
|
|
134
|
+
def compose(self) -> ComposeResult:
|
|
135
|
+
with Vertical(id="perm-box"):
|
|
136
|
+
yield Static(Text(_("回覆 {project} 的權限請求", project=self._project), style="bold"))
|
|
137
|
+
context = " · ".join(part for part in (self._dialog.title, self._dialog.question) if part)
|
|
138
|
+
if context:
|
|
139
|
+
yield Static(Text(context, style="grey50"))
|
|
140
|
+
yield OptionList(*(f"{n}. {text}" for n, text in self._dialog.options))
|
|
141
|
+
yield Static(Text(_("Enter 或數字鍵送出、Esc 取消"), style="grey50"))
|
|
142
|
+
|
|
143
|
+
def on_mount(self) -> None:
|
|
144
|
+
self.query_one(OptionList).focus()
|
|
145
|
+
|
|
146
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
147
|
+
self.dismiss(self._dialog.options[event.option_index][0])
|
|
148
|
+
|
|
149
|
+
def on_key(self, event: events.Key) -> None:
|
|
150
|
+
# 跟真的對話框一樣,直接按選項數字也行。
|
|
151
|
+
if event.key.isdigit() and any(int(event.key) == n for n, _text in self._dialog.options):
|
|
152
|
+
event.stop()
|
|
153
|
+
self.dismiss(int(event.key))
|
|
154
|
+
|
|
155
|
+
def key_escape(self) -> None:
|
|
156
|
+
self.dismiss(None)
|
|
157
|
+
|
|
158
|
+
|
|
101
159
|
class RingApp(App[None]):
|
|
102
160
|
"""場館的即時看板。"""
|
|
103
161
|
|
|
@@ -109,6 +167,7 @@ class RingApp(App[None]):
|
|
|
109
167
|
Binding("n", "name_session", _("命名")),
|
|
110
168
|
Binding("d", "delete_session", _("隱藏"), key_display="dd"),
|
|
111
169
|
Binding("w", "jump_oldest_waiting", _("最久等待")),
|
|
170
|
+
Binding("p", "permission_reply", _("回覆權限")),
|
|
112
171
|
Binding("enter", "jump", _("跳過去")),
|
|
113
172
|
Binding("space", "jump", _("跳過去"), show=False),
|
|
114
173
|
]
|
|
@@ -407,6 +466,78 @@ class RingApp(App[None]):
|
|
|
407
466
|
self.query_one(DataTable).move_cursor(row=idx)
|
|
408
467
|
self.action_jump()
|
|
409
468
|
|
|
469
|
+
def action_permission_reply(self) -> None:
|
|
470
|
+
"""游標列的 session 掛著權限對話框時,就地讀出選項讓你選、代按,不必跳過去。
|
|
471
|
+
|
|
472
|
+
支援 tmux 內的 session,以及(macOS 上)直接開在 iTerm2 分頁、沒有 tmux 的
|
|
473
|
+
session——backend 選擇見 ``permission.select_backend``。解析不到可辨識的權限
|
|
474
|
+
對話框就只 toast、絕不送鍵——沒有對話框時按鍵會落進聊天輸入框變成文字。
|
|
475
|
+
"""
|
|
476
|
+
self._clear_delete_armed()
|
|
477
|
+
s = self._selected()
|
|
478
|
+
if s is None:
|
|
479
|
+
self._set_status(_("(沒有選到 session)"))
|
|
480
|
+
return
|
|
481
|
+
name = self._display_name(s)
|
|
482
|
+
backend = permission.select_backend(s)
|
|
483
|
+
if backend is None:
|
|
484
|
+
self._toast(
|
|
485
|
+
_(
|
|
486
|
+
"{project}:沒有 tmux 座標,且非 macOS 上的 iTerm2 session,無法就地回覆",
|
|
487
|
+
project=name,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
return
|
|
491
|
+
screen = backend.capture()
|
|
492
|
+
if screen is None:
|
|
493
|
+
self._toast(_("{project}:讀不到 {backend} 畫面", project=name, backend=backend.name))
|
|
494
|
+
return
|
|
495
|
+
dialog = permission.parse_permission_dialog(screen)
|
|
496
|
+
if dialog is None:
|
|
497
|
+
self._toast(_("{project}:畫面上沒有可辨識的權限對話框,未送出任何按鍵", project=name))
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
def _submit(number: int | None) -> None:
|
|
501
|
+
if number is None:
|
|
502
|
+
return # Esc 取消,不送
|
|
503
|
+
self._finish_permission_reply(s, name, backend, dialog, number)
|
|
504
|
+
|
|
505
|
+
self.push_screen(_PermissionModal(name, dialog), _submit)
|
|
506
|
+
|
|
507
|
+
def _toast(self, text: str, *, ok: bool = False) -> None:
|
|
508
|
+
"""status 列+toast 一次到位(權限回覆路徑的訊息都成對出現)。"""
|
|
509
|
+
self._set_status(text)
|
|
510
|
+
self.notify(text, severity="information" if ok else "warning", timeout=8)
|
|
511
|
+
|
|
512
|
+
def _finish_permission_reply(
|
|
513
|
+
self,
|
|
514
|
+
s: Session,
|
|
515
|
+
name: str,
|
|
516
|
+
backend: permission.PermissionBackend,
|
|
517
|
+
dialog: permission.PermissionDialog,
|
|
518
|
+
number: int,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""浮層選定後:再驗證一次、送鍵、依結果回報(見 permission.send_permission_reply)。"""
|
|
521
|
+
outcome = permission.send_permission_reply(backend, dialog, number)
|
|
522
|
+
option = next((f"{n}. {text}" for n, text in dialog.options if n == number), str(number))
|
|
523
|
+
if outcome is permission.ReplyOutcome.OK:
|
|
524
|
+
if s.session_id == self._focused_sid:
|
|
525
|
+
self._focused_sid = None # 已就地回覆,解除通知標記
|
|
526
|
+
self._toast(_("→ {project}:已回覆權限({option})", project=name, option=option), ok=True)
|
|
527
|
+
self._reload()
|
|
528
|
+
return
|
|
529
|
+
messages = {
|
|
530
|
+
permission.ReplyOutcome.NO_DIALOG: _("{project}:權限對話框已不在,未送出任何按鍵", project=name),
|
|
531
|
+
permission.ReplyOutcome.CHANGED: _("{project}:對話框內容已變化,未送出;請再按一次 p", project=name),
|
|
532
|
+
permission.ReplyOutcome.SEND_FAILED: _("{project}:tmux send-keys 失敗,沒能回覆", project=name),
|
|
533
|
+
permission.ReplyOutcome.STILL_PRESENT: _("{project}:已送出但對話框還在,請跳過去確認", project=name),
|
|
534
|
+
permission.ReplyOutcome.MISFIRE: _(
|
|
535
|
+
"{project}:對話框已不在,數字落進輸入框,已補 Backspace 清掉", project=name
|
|
536
|
+
),
|
|
537
|
+
permission.ReplyOutcome.UNVERIFIED: _("{project}:已送出但拿不到畫面驗證,請跳過去確認", project=name),
|
|
538
|
+
}
|
|
539
|
+
self._toast(messages[outcome])
|
|
540
|
+
|
|
410
541
|
def action_delete_session(self) -> None:
|
|
411
542
|
s = self._selected()
|
|
412
543
|
if s is None:
|
|
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
|