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.
Files changed (56) hide show
  1. {ring_cli-0.10.0 → ring_cli-0.11.0}/PKG-INFO +27 -2
  2. {ring_cli-0.10.0 → ring_cli-0.11.0}/README.en.md +26 -1
  3. {ring_cli-0.10.0 → ring_cli-0.11.0}/pyproject.toml +1 -1
  4. ring_cli-0.11.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  5. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/locale/en/LC_MESSAGES/ring.po +39 -0
  6. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/locale/ring.pot +104 -40
  7. ring_cli-0.11.0/src/ring/permission.py +334 -0
  8. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/tui.py +133 -2
  9. ring_cli-0.10.0/src/ring/locale/en/LC_MESSAGES/ring.mo +0 -0
  10. {ring_cli-0.10.0 → ring_cli-0.11.0}/LICENSE +0 -0
  11. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/__init__.py +0 -0
  12. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/__main__.py +0 -0
  13. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/cli.py +0 -0
  14. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/__init__.py +0 -0
  15. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/_args.py +0 -0
  16. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/completion.py +0 -0
  17. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/digest.py +0 -0
  18. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/doctor.py +0 -0
  19. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/focus.py +0 -0
  20. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/gc.py +0 -0
  21. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/hook.py +0 -0
  22. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/commands/stats.py +0 -0
  23. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/config.py +0 -0
  24. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/__init__.py +0 -0
  25. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/applescript.py +0 -0
  26. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/base.py +0 -0
  27. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/iterm2.py +0 -0
  28. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/linux_wm.py +0 -0
  29. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/neovim.py +0 -0
  30. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/terminal.py +0 -0
  31. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/focus/tmux.py +0 -0
  32. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/gc.py +0 -0
  33. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/hook.py +0 -0
  34. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/hook_protocol.py +0 -0
  35. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/i18n.py +0 -0
  36. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/ipc.py +0 -0
  37. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/labels.py +0 -0
  38. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/__init__.py +0 -0
  39. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/base.py +0 -0
  40. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/command.py +0 -0
  41. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/notify_send.py +0 -0
  42. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/ntfy.py +0 -0
  43. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/osascript_notifier.py +0 -0
  44. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/terminal_notifier.py +0 -0
  45. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/notify/webhook.py +0 -0
  46. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/osascript.py +0 -0
  47. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/plugins.py +0 -0
  48. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/registry.py +0 -0
  49. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/__init__.py +0 -0
  50. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/base.py +0 -0
  51. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/claude_code.py +0 -0
  52. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/codex.py +0 -0
  53. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/sources/hook_registry.py +0 -0
  54. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/stats.py +0 -0
  55. {ring_cli-0.10.0 → ring_cli-0.11.0}/src/ring/transcript.py +0 -0
  56. {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.10.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.10.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"
@@ -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-10 15:32+0800\n"
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:213
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:168
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:170
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:171
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:171
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:171
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:171
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:171
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:406
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:407
313
+ #: src/ring/hook.py:439
314
314
  msgid "它們會跟 RiNG 的通知重複觸發;要完全改用 RiNG,建議移除它們。"
315
315
  msgstr ""
316
316
 
317
- #: src/ring/hook.py:455 src/ring/hook.py:531
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:493
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:506
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:525
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:552
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:556
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:88
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:89
357
+ #: src/ring/tui.py:92
358
358
  msgid "這個 session 在做什麼…"
359
359
  msgstr ""
360
360
 
361
- #: src/ring/tui.py:106
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:107
374
+ #: src/ring/tui.py:165
366
375
  msgid "刷新"
367
376
  msgstr ""
368
377
 
369
- #: src/ring/tui.py:108
378
+ #: src/ring/tui.py:166
370
379
  msgid "含已離場"
371
380
  msgstr ""
372
381
 
373
- #: src/ring/tui.py:109
382
+ #: src/ring/tui.py:167
374
383
  msgid "命名"
375
384
  msgstr ""
376
385
 
377
- #: src/ring/tui.py:110
386
+ #: src/ring/tui.py:168
378
387
  msgid "隱藏"
379
388
  msgstr ""
380
389
 
381
- #: src/ring/tui.py:111
390
+ #: src/ring/tui.py:169
382
391
  msgid "最久等待"
383
392
  msgstr ""
384
393
 
385
- #: src/ring/tui.py:112 src/ring/tui.py:113
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:189
402
+ #: src/ring/tui.py:250
390
403
  msgid "💡 同專案開了多個 session,裝 hook 跳轉才精準:ring install-hooks"
391
404
  msgstr ""
392
405
 
393
- #: src/ring/tui.py:191
406
+ #: src/ring/tui.py:252
394
407
  msgid "↑/↓ 選一列,Enter 或 Space 跳到那個 session 的終端"
395
408
  msgstr ""
396
409
 
397
- #: src/ring/tui.py:227
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:268
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:273
420
+ #: src/ring/tui.py:334
408
421
  msgid "那個 session 已不在場"
409
422
  msgstr ""
410
423
 
411
- #: src/ring/tui.py:332
424
+ #: src/ring/tui.py:393
412
425
  msgid " ⚠ hook 可能失效:來源檔有更新但沒有 heartbeat"
413
426
  msgstr ""
414
427
 
415
- #: src/ring/tui.py:361 src/ring/tui.py:385 src/ring/tui.py:411
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:392
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:394
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:402
443
+ #: src/ring/tui.py:463
430
444
  msgid "(沒有正在等待的 session)"
431
445
  msgstr ""
432
446
 
433
- #: src/ring/tui.py:419
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:424
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:432
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:434
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(有新活動會自動重新出現)、r 刷新、q 離場。
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:
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