ripperdoc 0.3.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/ui/interrupt_listener.py +233 -0
- ripperdoc/cli/ui/message_display.py +7 -0
- ripperdoc/cli/ui/rich_ui.py +83 -73
- ripperdoc/core/permissions.py +105 -98
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.1.dist-info}/METADATA +1 -1
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.1.dist-info}/RECORD +11 -11
- ripperdoc/cli/ui/interrupt_handler.py +0 -208
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.1.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.1.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.1.dist-info}/top_level.txt +0 -0
ripperdoc/__init__.py
CHANGED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""ESC key interrupt listener for the Rich UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
from ripperdoc.utils.log import get_logger
|
|
12
|
+
|
|
13
|
+
if os.name != "nt":
|
|
14
|
+
import select
|
|
15
|
+
import termios
|
|
16
|
+
import tty
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EscInterruptListener:
|
|
20
|
+
"""Listen for ESC keypresses in a background thread and invoke a callback."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, on_interrupt: Callable[[], None], *, logger: Optional[Any] = None) -> None:
|
|
23
|
+
self._on_interrupt = on_interrupt
|
|
24
|
+
self._logger = logger or get_logger()
|
|
25
|
+
self._thread: Optional[threading.Thread] = None
|
|
26
|
+
self._stop_event = threading.Event()
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self._pause_depth = 0
|
|
29
|
+
self._interrupt_sent = False
|
|
30
|
+
self._fd: Optional[int] = None
|
|
31
|
+
self._owns_fd = False
|
|
32
|
+
self._orig_termios = None
|
|
33
|
+
self._cbreak_active = False
|
|
34
|
+
self._availability_checked = False
|
|
35
|
+
self._available = True
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_running(self) -> bool:
|
|
39
|
+
return self._thread is not None and self._thread.is_alive()
|
|
40
|
+
|
|
41
|
+
def start(self) -> None:
|
|
42
|
+
if self.is_running or not self._available:
|
|
43
|
+
return
|
|
44
|
+
if os.name != "nt" and not self._setup_posix_input():
|
|
45
|
+
return
|
|
46
|
+
self._stop_event.clear()
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._pause_depth = 0
|
|
49
|
+
self._interrupt_sent = False
|
|
50
|
+
self._thread = threading.Thread(
|
|
51
|
+
target=self._run,
|
|
52
|
+
name="ripperdoc-esc-listener",
|
|
53
|
+
daemon=True,
|
|
54
|
+
)
|
|
55
|
+
self._thread.start()
|
|
56
|
+
|
|
57
|
+
def stop(self) -> None:
|
|
58
|
+
self._stop_event.set()
|
|
59
|
+
if self._thread is not None:
|
|
60
|
+
self._thread.join(timeout=0.25)
|
|
61
|
+
self._thread = None
|
|
62
|
+
if os.name != "nt":
|
|
63
|
+
self._restore_posix_input()
|
|
64
|
+
|
|
65
|
+
def pause(self) -> None:
|
|
66
|
+
if os.name == "nt":
|
|
67
|
+
return
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._pause_depth += 1
|
|
70
|
+
if self._pause_depth == 1:
|
|
71
|
+
self._restore_termios_locked()
|
|
72
|
+
|
|
73
|
+
def resume(self) -> None:
|
|
74
|
+
if os.name == "nt":
|
|
75
|
+
return
|
|
76
|
+
with self._lock:
|
|
77
|
+
if self._pause_depth == 0:
|
|
78
|
+
return
|
|
79
|
+
self._pause_depth -= 1
|
|
80
|
+
if self._pause_depth == 0:
|
|
81
|
+
self._apply_cbreak_locked()
|
|
82
|
+
|
|
83
|
+
def _run(self) -> None:
|
|
84
|
+
if os.name == "nt":
|
|
85
|
+
self._run_windows()
|
|
86
|
+
else:
|
|
87
|
+
self._run_posix()
|
|
88
|
+
|
|
89
|
+
def _run_windows(self) -> None:
|
|
90
|
+
import msvcrt
|
|
91
|
+
|
|
92
|
+
while not self._stop_event.is_set():
|
|
93
|
+
with self._lock:
|
|
94
|
+
paused = self._pause_depth > 0
|
|
95
|
+
if paused:
|
|
96
|
+
time.sleep(0.05)
|
|
97
|
+
continue
|
|
98
|
+
if msvcrt.kbhit():
|
|
99
|
+
ch = msvcrt.getwch()
|
|
100
|
+
if ch == "\x1b":
|
|
101
|
+
self._signal_interrupt()
|
|
102
|
+
time.sleep(0.02)
|
|
103
|
+
|
|
104
|
+
def _run_posix(self) -> None:
|
|
105
|
+
while not self._stop_event.is_set():
|
|
106
|
+
with self._lock:
|
|
107
|
+
paused = self._pause_depth > 0
|
|
108
|
+
fd = self._fd
|
|
109
|
+
if paused or fd is None:
|
|
110
|
+
time.sleep(0.05)
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
readable, _, _ = select.select([fd], [], [], 0.1)
|
|
114
|
+
except (OSError, ValueError):
|
|
115
|
+
time.sleep(0.05)
|
|
116
|
+
continue
|
|
117
|
+
if not readable:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
ch = os.read(fd, 1)
|
|
121
|
+
except OSError:
|
|
122
|
+
continue
|
|
123
|
+
if ch == b"\x1b":
|
|
124
|
+
if self._is_escape_sequence(fd):
|
|
125
|
+
continue
|
|
126
|
+
self._signal_interrupt()
|
|
127
|
+
|
|
128
|
+
def _is_escape_sequence(self, fd: int) -> bool:
|
|
129
|
+
try:
|
|
130
|
+
readable, _, _ = select.select([fd], [], [], 0.02)
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
return False
|
|
133
|
+
if not readable:
|
|
134
|
+
return False
|
|
135
|
+
self._drain_pending_bytes(fd)
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def _drain_pending_bytes(self, fd: int) -> None:
|
|
139
|
+
while True:
|
|
140
|
+
try:
|
|
141
|
+
readable, _, _ = select.select([fd], [], [], 0)
|
|
142
|
+
except (OSError, ValueError):
|
|
143
|
+
return
|
|
144
|
+
if not readable:
|
|
145
|
+
return
|
|
146
|
+
try:
|
|
147
|
+
os.read(fd, 32)
|
|
148
|
+
except OSError:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
def _signal_interrupt(self) -> None:
|
|
152
|
+
with self._lock:
|
|
153
|
+
if self._interrupt_sent:
|
|
154
|
+
return
|
|
155
|
+
self._interrupt_sent = True
|
|
156
|
+
try:
|
|
157
|
+
self._on_interrupt()
|
|
158
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
159
|
+
self._logger.debug(
|
|
160
|
+
"[ui] ESC interrupt callback failed: %s: %s",
|
|
161
|
+
type(exc).__name__,
|
|
162
|
+
exc,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _setup_posix_input(self) -> bool:
|
|
166
|
+
if self._fd is not None:
|
|
167
|
+
return True
|
|
168
|
+
fd: Optional[int] = None
|
|
169
|
+
owns = False
|
|
170
|
+
try:
|
|
171
|
+
if sys.stdin.isatty():
|
|
172
|
+
fd = sys.stdin.fileno()
|
|
173
|
+
elif os.path.exists("/dev/tty"):
|
|
174
|
+
fd = os.open("/dev/tty", os.O_RDONLY)
|
|
175
|
+
owns = True
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
self._disable_listener(f"input error: {exc}")
|
|
178
|
+
return False
|
|
179
|
+
if fd is None:
|
|
180
|
+
self._disable_listener("no TTY available")
|
|
181
|
+
return False
|
|
182
|
+
try:
|
|
183
|
+
self._orig_termios = termios.tcgetattr(fd)
|
|
184
|
+
except (termios.error, OSError) as exc:
|
|
185
|
+
if owns:
|
|
186
|
+
try:
|
|
187
|
+
os.close(fd)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
self._disable_listener(f"termios unavailable: {exc}")
|
|
191
|
+
return False
|
|
192
|
+
self._fd = fd
|
|
193
|
+
self._owns_fd = owns
|
|
194
|
+
self._apply_cbreak_locked()
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
def _restore_posix_input(self) -> None:
|
|
198
|
+
with self._lock:
|
|
199
|
+
self._restore_termios_locked()
|
|
200
|
+
if self._fd is not None and self._owns_fd:
|
|
201
|
+
try:
|
|
202
|
+
os.close(self._fd)
|
|
203
|
+
except OSError:
|
|
204
|
+
pass
|
|
205
|
+
self._fd = None
|
|
206
|
+
self._owns_fd = False
|
|
207
|
+
self._orig_termios = None
|
|
208
|
+
self._cbreak_active = False
|
|
209
|
+
|
|
210
|
+
def _apply_cbreak_locked(self) -> None:
|
|
211
|
+
if self._fd is None or self._orig_termios is None or self._cbreak_active:
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
tty.setcbreak(self._fd)
|
|
215
|
+
self._cbreak_active = True
|
|
216
|
+
except (termios.error, OSError):
|
|
217
|
+
self._disable_listener("failed to enter cbreak mode")
|
|
218
|
+
|
|
219
|
+
def _restore_termios_locked(self) -> None:
|
|
220
|
+
if self._fd is None or self._orig_termios is None or not self._cbreak_active:
|
|
221
|
+
return
|
|
222
|
+
try:
|
|
223
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._orig_termios)
|
|
224
|
+
except (termios.error, OSError):
|
|
225
|
+
pass
|
|
226
|
+
self._cbreak_active = False
|
|
227
|
+
|
|
228
|
+
def _disable_listener(self, reason: str) -> None:
|
|
229
|
+
if self._availability_checked:
|
|
230
|
+
return
|
|
231
|
+
self._availability_checked = True
|
|
232
|
+
self._available = False
|
|
233
|
+
self._logger.debug("[ui] ESC interrupt listener disabled: %s", reason)
|
|
@@ -218,6 +218,13 @@ class MessageDisplay:
|
|
|
218
218
|
if preview:
|
|
219
219
|
self.console.print(f"[dim italic]Thinking: {escape(preview)}[/]")
|
|
220
220
|
|
|
221
|
+
def print_interrupt_notice(self) -> None:
|
|
222
|
+
"""Display an interrupt notice when the user cancels with ESC."""
|
|
223
|
+
self.console.print(
|
|
224
|
+
"\n[red]■ Conversation interrupted[/red] · "
|
|
225
|
+
"[dim]Tell the model what to do differently.[/dim]"
|
|
226
|
+
)
|
|
227
|
+
|
|
221
228
|
|
|
222
229
|
def parse_bash_output_sections(content: str) -> Tuple[List[str], List[str]]:
|
|
223
230
|
"""Parse stdout/stderr sections from a bash output text block."""
|
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -47,7 +47,7 @@ from ripperdoc.cli.ui.thinking_spinner import ThinkingSpinner
|
|
|
47
47
|
from ripperdoc.cli.ui.context_display import context_usage_lines
|
|
48
48
|
from ripperdoc.cli.ui.panels import create_welcome_panel, create_status_bar, print_shortcuts
|
|
49
49
|
from ripperdoc.cli.ui.message_display import MessageDisplay, parse_bash_output_sections
|
|
50
|
-
from ripperdoc.cli.ui.
|
|
50
|
+
from ripperdoc.cli.ui.interrupt_listener import EscInterruptListener
|
|
51
51
|
from ripperdoc.utils.conversation_compaction import (
|
|
52
52
|
compact_conversation,
|
|
53
53
|
CompactionResult,
|
|
@@ -77,6 +77,8 @@ from ripperdoc.utils.messages import (
|
|
|
77
77
|
UserMessage,
|
|
78
78
|
AssistantMessage,
|
|
79
79
|
ProgressMessage,
|
|
80
|
+
INTERRUPT_MESSAGE,
|
|
81
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
80
82
|
create_user_message,
|
|
81
83
|
)
|
|
82
84
|
from ripperdoc.utils.log import enable_session_file_logging, get_logger
|
|
@@ -349,6 +351,10 @@ class RichUI:
|
|
|
349
351
|
self._exit_reason: Optional[str] = None
|
|
350
352
|
self._using_tty_input = False # Track if we're using /dev/tty for input
|
|
351
353
|
self._thinking_mode_enabled = False # Toggle for extended thinking mode
|
|
354
|
+
self._interrupt_listener = EscInterruptListener(self._schedule_esc_interrupt, logger=logger)
|
|
355
|
+
self._esc_interrupt_seen = False
|
|
356
|
+
self._query_in_progress = False
|
|
357
|
+
self._active_spinner: Optional[ThinkingSpinner] = None
|
|
352
358
|
hook_manager.set_transcript_path(str(self._session_history.path))
|
|
353
359
|
|
|
354
360
|
# Create permission checker with Rich console and PromptSession support
|
|
@@ -398,8 +404,6 @@ class RichUI:
|
|
|
398
404
|
|
|
399
405
|
# Initialize component handlers
|
|
400
406
|
self._message_display = MessageDisplay(self.console, self.verbose, self.show_full_thinking)
|
|
401
|
-
self._interrupt_handler = InterruptHandler()
|
|
402
|
-
self._interrupt_handler.set_abort_callback(self._trigger_abort)
|
|
403
407
|
|
|
404
408
|
# Keep MCP runtime alive for the whole UI session. Create it on the UI loop up front.
|
|
405
409
|
try:
|
|
@@ -440,18 +444,6 @@ class RichUI:
|
|
|
440
444
|
# Properties for backward compatibility with interrupt handler
|
|
441
445
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
442
446
|
|
|
443
|
-
@property
|
|
444
|
-
def _query_interrupted(self) -> bool:
|
|
445
|
-
return self._interrupt_handler.was_interrupted
|
|
446
|
-
|
|
447
|
-
@property
|
|
448
|
-
def _esc_listener_paused(self) -> bool:
|
|
449
|
-
return self._interrupt_handler._esc_listener_paused
|
|
450
|
-
|
|
451
|
-
@_esc_listener_paused.setter
|
|
452
|
-
def _esc_listener_paused(self, value: bool) -> None:
|
|
453
|
-
self._interrupt_handler._esc_listener_paused = value
|
|
454
|
-
|
|
455
447
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
456
448
|
# Thinking mode toggle
|
|
457
449
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -924,6 +916,11 @@ class RichUI:
|
|
|
924
916
|
last_tool_name: Optional[str] = None
|
|
925
917
|
|
|
926
918
|
if isinstance(message.message.content, str):
|
|
919
|
+
if self._esc_interrupt_seen and message.message.content.strip() in (
|
|
920
|
+
INTERRUPT_MESSAGE,
|
|
921
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
922
|
+
):
|
|
923
|
+
return last_tool_name
|
|
927
924
|
with pause():
|
|
928
925
|
self.display_message("Ripperdoc", message.message.content)
|
|
929
926
|
elif isinstance(message.message.content, list):
|
|
@@ -1156,11 +1153,26 @@ class RichUI:
|
|
|
1156
1153
|
spinner = ThinkingSpinner(console, prompt_tokens_est)
|
|
1157
1154
|
|
|
1158
1155
|
def pause_ui() -> None:
|
|
1159
|
-
|
|
1156
|
+
self._pause_interrupt_listener()
|
|
1157
|
+
try:
|
|
1158
|
+
spinner.stop()
|
|
1159
|
+
except (RuntimeError, ValueError, OSError):
|
|
1160
|
+
logger.debug("[ui] Failed to pause spinner")
|
|
1160
1161
|
|
|
1161
1162
|
def resume_ui() -> None:
|
|
1162
|
-
|
|
1163
|
-
|
|
1163
|
+
if self._esc_interrupt_seen:
|
|
1164
|
+
return
|
|
1165
|
+
try:
|
|
1166
|
+
spinner.start()
|
|
1167
|
+
spinner.update("Thinking...")
|
|
1168
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
1169
|
+
logger.debug(
|
|
1170
|
+
"[ui] Failed to restart spinner after pause: %s: %s",
|
|
1171
|
+
type(exc).__name__,
|
|
1172
|
+
exc,
|
|
1173
|
+
)
|
|
1174
|
+
finally:
|
|
1175
|
+
self._resume_interrupt_listener()
|
|
1164
1176
|
|
|
1165
1177
|
self.query_context.pause_ui = pause_ui
|
|
1166
1178
|
self.query_context.resume_ui = resume_ui
|
|
@@ -1169,8 +1181,7 @@ class RichUI:
|
|
|
1169
1181
|
base_permission_checker = self._permission_checker
|
|
1170
1182
|
|
|
1171
1183
|
async def permission_checker(tool: Any, parsed_input: Any) -> bool:
|
|
1172
|
-
|
|
1173
|
-
was_paused = self._pause_interrupt_listener()
|
|
1184
|
+
pause_ui()
|
|
1174
1185
|
try:
|
|
1175
1186
|
if base_permission_checker is not None:
|
|
1176
1187
|
result = await base_permission_checker(tool, parsed_input)
|
|
@@ -1186,18 +1197,7 @@ class RichUI:
|
|
|
1186
1197
|
return allowed
|
|
1187
1198
|
return True
|
|
1188
1199
|
finally:
|
|
1189
|
-
|
|
1190
|
-
# Wrap spinner restart in try-except to prevent exceptions
|
|
1191
|
-
# from discarding the permission result
|
|
1192
|
-
try:
|
|
1193
|
-
spinner.start()
|
|
1194
|
-
spinner.update("Thinking...")
|
|
1195
|
-
except (RuntimeError, ValueError, OSError) as exc:
|
|
1196
|
-
logger.debug(
|
|
1197
|
-
"[ui] Failed to restart spinner after permission check: %s: %s",
|
|
1198
|
-
type(exc).__name__,
|
|
1199
|
-
exc,
|
|
1200
|
-
)
|
|
1200
|
+
resume_ui()
|
|
1201
1201
|
|
|
1202
1202
|
# Process query stream
|
|
1203
1203
|
tool_registry: Dict[str, Dict[str, Any]] = {}
|
|
@@ -1205,6 +1205,10 @@ class RichUI:
|
|
|
1205
1205
|
output_token_est = 0
|
|
1206
1206
|
|
|
1207
1207
|
try:
|
|
1208
|
+
self._active_spinner = spinner
|
|
1209
|
+
self._esc_interrupt_seen = False
|
|
1210
|
+
self._query_in_progress = True
|
|
1211
|
+
self._start_interrupt_listener()
|
|
1208
1212
|
spinner.start()
|
|
1209
1213
|
async for message in query(
|
|
1210
1214
|
messages,
|
|
@@ -1253,6 +1257,9 @@ class RichUI:
|
|
|
1253
1257
|
extra={"session_id": self.session_id},
|
|
1254
1258
|
)
|
|
1255
1259
|
|
|
1260
|
+
self._stop_interrupt_listener()
|
|
1261
|
+
self._query_in_progress = False
|
|
1262
|
+
self._active_spinner = None
|
|
1256
1263
|
self.conversation_messages = messages
|
|
1257
1264
|
logger.info(
|
|
1258
1265
|
"[ui] Query processing completed",
|
|
@@ -1279,21 +1286,49 @@ class RichUI:
|
|
|
1279
1286
|
# ESC Key Interrupt Support
|
|
1280
1287
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1281
1288
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1289
|
+
def _schedule_esc_interrupt(self) -> None:
|
|
1290
|
+
"""Schedule ESC interrupt handling on the UI event loop."""
|
|
1291
|
+
if self._loop.is_closed():
|
|
1292
|
+
return
|
|
1293
|
+
try:
|
|
1294
|
+
self._loop.call_soon_threadsafe(self._handle_esc_interrupt)
|
|
1295
|
+
except RuntimeError:
|
|
1296
|
+
pass
|
|
1297
|
+
|
|
1298
|
+
def _handle_esc_interrupt(self) -> None:
|
|
1299
|
+
"""Abort the current query and display the interrupt notice."""
|
|
1300
|
+
if not self._query_in_progress:
|
|
1301
|
+
return
|
|
1302
|
+
if self._esc_interrupt_seen:
|
|
1303
|
+
return
|
|
1304
|
+
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
1305
|
+
if abort_controller is None or abort_controller.is_set():
|
|
1306
|
+
return
|
|
1307
|
+
|
|
1308
|
+
self._esc_interrupt_seen = True
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
if self.query_context and self.query_context.pause_ui:
|
|
1312
|
+
self.query_context.pause_ui()
|
|
1313
|
+
elif self._active_spinner:
|
|
1314
|
+
self._active_spinner.stop()
|
|
1315
|
+
except (RuntimeError, ValueError, OSError):
|
|
1316
|
+
logger.debug("[ui] Failed to pause spinner for ESC interrupt")
|
|
1317
|
+
|
|
1318
|
+
self._message_display.print_interrupt_notice()
|
|
1319
|
+
abort_controller.set()
|
|
1285
1320
|
|
|
1286
|
-
def
|
|
1287
|
-
self.
|
|
1321
|
+
def _start_interrupt_listener(self) -> None:
|
|
1322
|
+
self._interrupt_listener.start()
|
|
1288
1323
|
|
|
1289
|
-
def
|
|
1290
|
-
|
|
1291
|
-
if self.query_context and hasattr(self.query_context, "abort_controller"):
|
|
1292
|
-
self.query_context.abort_controller.set()
|
|
1324
|
+
def _stop_interrupt_listener(self) -> None:
|
|
1325
|
+
self._interrupt_listener.stop()
|
|
1293
1326
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1327
|
+
def _pause_interrupt_listener(self) -> None:
|
|
1328
|
+
self._interrupt_listener.pause()
|
|
1329
|
+
|
|
1330
|
+
def _resume_interrupt_listener(self) -> None:
|
|
1331
|
+
self._interrupt_listener.resume()
|
|
1297
1332
|
|
|
1298
1333
|
def _run_async(self, coro: Any) -> Any:
|
|
1299
1334
|
"""Run a coroutine on the persistent event loop."""
|
|
@@ -1302,16 +1337,6 @@ class RichUI:
|
|
|
1302
1337
|
asyncio.set_event_loop(self._loop)
|
|
1303
1338
|
return self._loop.run_until_complete(coro)
|
|
1304
1339
|
|
|
1305
|
-
def _run_async_with_esc_interrupt(self, coro: Any) -> bool:
|
|
1306
|
-
"""Run a coroutine with ESC key interrupt support.
|
|
1307
|
-
|
|
1308
|
-
Returns True if interrupted by ESC, False if completed normally.
|
|
1309
|
-
"""
|
|
1310
|
-
if self._loop.is_closed():
|
|
1311
|
-
self._loop = asyncio.new_event_loop()
|
|
1312
|
-
asyncio.set_event_loop(self._loop)
|
|
1313
|
-
return self._loop.run_until_complete(self._run_query_with_esc_interrupt(coro))
|
|
1314
|
-
|
|
1315
1340
|
def run_async(self, coro: Any) -> Any:
|
|
1316
1341
|
"""Public wrapper for running coroutines on the UI event loop."""
|
|
1317
1342
|
return self._run_async(coro)
|
|
@@ -1537,8 +1562,7 @@ class RichUI:
|
|
|
1537
1562
|
console.print()
|
|
1538
1563
|
console.print(
|
|
1539
1564
|
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. "
|
|
1540
|
-
"Press Alt+Enter for newline. Press Tab to toggle thinking mode.
|
|
1541
|
-
"Press ESC to interrupt.[/dim]\n"
|
|
1565
|
+
"Press Alt+Enter for newline. Press Tab to toggle thinking mode.[/dim]\n"
|
|
1542
1566
|
)
|
|
1543
1567
|
|
|
1544
1568
|
session = self.get_prompt_session()
|
|
@@ -1562,8 +1586,7 @@ class RichUI:
|
|
|
1562
1586
|
)
|
|
1563
1587
|
console.print() # Add spacing before response
|
|
1564
1588
|
|
|
1565
|
-
#
|
|
1566
|
-
# since there's no TTY for ESC key detection
|
|
1589
|
+
# Process initial query (ESC interrupt handling removed)
|
|
1567
1590
|
self._run_async(self.process_query(self._initial_query))
|
|
1568
1591
|
|
|
1569
1592
|
logger.info(
|
|
@@ -1614,21 +1637,8 @@ class RichUI:
|
|
|
1614
1637
|
},
|
|
1615
1638
|
)
|
|
1616
1639
|
|
|
1617
|
-
#
|
|
1618
|
-
|
|
1619
|
-
self._run_async(self.process_query(user_input))
|
|
1620
|
-
else:
|
|
1621
|
-
interrupted = self._run_async_with_esc_interrupt(
|
|
1622
|
-
self.process_query(user_input)
|
|
1623
|
-
)
|
|
1624
|
-
if interrupted:
|
|
1625
|
-
console.print(
|
|
1626
|
-
"\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
|
|
1627
|
-
)
|
|
1628
|
-
logger.info(
|
|
1629
|
-
"[ui] Query interrupted by ESC key",
|
|
1630
|
-
extra={"session_id": self.session_id},
|
|
1631
|
-
)
|
|
1640
|
+
# Run query (ESC interrupt handling removed)
|
|
1641
|
+
self._run_async(self.process_query(user_input))
|
|
1632
1642
|
|
|
1633
1643
|
console.print() # Add spacing between interactions
|
|
1634
1644
|
|
ripperdoc/core/permissions.py
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import html
|
|
6
7
|
from collections import defaultdict
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any, Awaitable, Callable, Optional, Set, TYPE_CHECKING, TYPE_CHECKING as TYPE_CHECKING
|
|
10
11
|
|
|
12
|
+
from prompt_toolkit.filters import is_done
|
|
13
|
+
from prompt_toolkit.formatted_text import HTML
|
|
14
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
15
|
+
from prompt_toolkit.shortcuts import choice
|
|
16
|
+
from prompt_toolkit.styles import Style
|
|
17
|
+
|
|
11
18
|
from ripperdoc.core.config import config_manager
|
|
12
19
|
from ripperdoc.core.hooks.manager import hook_manager
|
|
13
20
|
from ripperdoc.core.tool import Tool
|
|
@@ -36,35 +43,37 @@ def _format_input_preview(parsed_input: Any, tool_name: Optional[str] = None) ->
|
|
|
36
43
|
|
|
37
44
|
For Bash commands, shows full details for security review.
|
|
38
45
|
For other tools, shows a concise preview.
|
|
46
|
+
Returns HTML-formatted text with color tags.
|
|
39
47
|
"""
|
|
40
48
|
# For Bash tool, show full command details for security review
|
|
41
49
|
if tool_name == "Bash" and hasattr(parsed_input, "command"):
|
|
42
|
-
|
|
50
|
+
command = html.escape(getattr(parsed_input, "command"))
|
|
51
|
+
lines = [f"<label>Command:</label> <value>{command}</value>"]
|
|
43
52
|
|
|
44
53
|
# Add other relevant parameters
|
|
45
54
|
if hasattr(parsed_input, "timeout") and parsed_input.timeout:
|
|
46
|
-
lines.append(f"Timeout
|
|
55
|
+
lines.append(f"<label>Timeout:</label> <value>{parsed_input.timeout}ms</value>")
|
|
47
56
|
if hasattr(parsed_input, "sandbox"):
|
|
48
|
-
lines.append(f"Sandbox
|
|
57
|
+
lines.append(f"<label>Sandbox:</label> <value>{parsed_input.sandbox}</value>")
|
|
49
58
|
if hasattr(parsed_input, "run_in_background"):
|
|
50
|
-
lines.append(f"Background
|
|
59
|
+
lines.append(f"<label>Background:</label> <value>{parsed_input.run_in_background}</value>")
|
|
51
60
|
if hasattr(parsed_input, "shell_executable") and parsed_input.shell_executable:
|
|
52
|
-
lines.append(f"Shell
|
|
61
|
+
lines.append(f"<label>Shell:</label> <value>{html.escape(parsed_input.shell_executable)}</value>")
|
|
53
62
|
|
|
54
63
|
return "\n ".join(lines)
|
|
55
64
|
|
|
56
65
|
# For other tools with commands, show concise preview
|
|
57
66
|
if hasattr(parsed_input, "command"):
|
|
58
|
-
return f"command
|
|
67
|
+
return f"<label>command:</label> <value>'{html.escape(getattr(parsed_input, 'command'))}'</value>"
|
|
59
68
|
if hasattr(parsed_input, "file_path"):
|
|
60
|
-
return f"file
|
|
69
|
+
return f"<label>file:</label> <value>'{html.escape(getattr(parsed_input, 'file_path'))}'</value>"
|
|
61
70
|
if hasattr(parsed_input, "path"):
|
|
62
|
-
return f"path
|
|
71
|
+
return f"<label>path:</label> <value>'{html.escape(getattr(parsed_input, 'path'))}'</value>"
|
|
63
72
|
|
|
64
73
|
preview = str(parsed_input)
|
|
65
74
|
if len(preview) > 140:
|
|
66
|
-
|
|
67
|
-
return preview
|
|
75
|
+
preview = preview[:137] + "..."
|
|
76
|
+
return f"<value>{html.escape(preview)}</value>"
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
|
|
@@ -92,53 +101,23 @@ def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
|
|
|
92
101
|
return tool.name
|
|
93
102
|
|
|
94
103
|
|
|
95
|
-
def
|
|
96
|
-
"""
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
options: list[tuple[str, str]]
|
|
112
|
-
) -> None:
|
|
113
|
-
"""Render permission dialog using Rich Panel for better visual consistency."""
|
|
114
|
-
from rich.panel import Panel
|
|
115
|
-
from rich.text import Text
|
|
116
|
-
|
|
117
|
-
# Build option lines with markup
|
|
118
|
-
option_lines = []
|
|
119
|
-
for idx, (_, label) in enumerate(options, start=1):
|
|
120
|
-
prefix = "[cyan]❯[/cyan]" if idx == 1 else " "
|
|
121
|
-
option_lines.append(f"{prefix} {idx}. {label}")
|
|
122
|
-
|
|
123
|
-
numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
|
|
124
|
-
shortcut_choices = "/".join(opt[0] for opt in options)
|
|
125
|
-
|
|
126
|
-
# Build the prompt content as a markup string
|
|
127
|
-
markup_content = f"{prompt}\n\n" + "\n".join(option_lines) + "\n"
|
|
128
|
-
markup_content += f"Choice ([cyan]{numeric_choices}[/cyan] or [cyan]{shortcut_choices}[/cyan]): "
|
|
129
|
-
|
|
130
|
-
# Parse markup to create a Text object
|
|
131
|
-
content = Text.from_markup(markup_content)
|
|
132
|
-
|
|
133
|
-
# Render the panel
|
|
134
|
-
panel = Panel(
|
|
135
|
-
content,
|
|
136
|
-
title=Text.from_markup("[yellow]Permission Required[/yellow]"),
|
|
137
|
-
title_align="left",
|
|
138
|
-
border_style="yellow",
|
|
139
|
-
padding=(0, 1),
|
|
104
|
+
def _permission_style() -> Style:
|
|
105
|
+
"""Create the style for permission choice prompts."""
|
|
106
|
+
return Style.from_dict(
|
|
107
|
+
{
|
|
108
|
+
"frame.border": "#d4a017", # Golden/amber border
|
|
109
|
+
"selected-option": "bold",
|
|
110
|
+
"option": "#5fd7ff", # Cyan for unselected options
|
|
111
|
+
"title": "#ffaf00", # Orange/amber for tool name
|
|
112
|
+
"description": "#ffffff", # White for descriptions
|
|
113
|
+
"question": "#ffd700", # Gold for the question
|
|
114
|
+
"label": "#87afff", # Light blue for field labels (Command:, Sandbox:, etc.)
|
|
115
|
+
"warning": "#ff5555", # Red for warnings
|
|
116
|
+
"yes-option": "#ffffff", # Neutral for Yes options
|
|
117
|
+
"no-option": "#ffffff", # Neutral for No option
|
|
118
|
+
"value": "#f8f8f2", # Off-white for values
|
|
119
|
+
}
|
|
140
120
|
)
|
|
141
|
-
console.print(panel)
|
|
142
121
|
|
|
143
122
|
|
|
144
123
|
def _rule_strings(rule_suggestions: Optional[Any]) -> list[str]:
|
|
@@ -179,41 +158,68 @@ def make_permission_checker(
|
|
|
179
158
|
session_allowed_tools: Set[str] = set()
|
|
180
159
|
session_tool_rules: dict[str, Set[str]] = defaultdict(set)
|
|
181
160
|
|
|
182
|
-
async def _prompt_user(prompt: str, options: list[tuple[str, str]]) -> str:
|
|
183
|
-
"""Prompt the user with proper interrupt handling.
|
|
184
|
-
# Build the prompt message
|
|
185
|
-
if console is not None:
|
|
186
|
-
# Use Rich Panel for the dialog
|
|
187
|
-
_render_options_prompt_rich(console, prompt, options)
|
|
188
|
-
# Build simple prompt for the input line
|
|
189
|
-
numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
|
|
190
|
-
shortcut_choices = "/".join(opt[0] for opt in options)
|
|
191
|
-
input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
|
|
192
|
-
else:
|
|
193
|
-
# Use plain text rendering
|
|
194
|
-
rendered = _render_options_prompt(prompt, options)
|
|
195
|
-
input_prompt = rendered
|
|
196
|
-
|
|
197
|
-
# Try to use PromptSession if available (better interrupt handling)
|
|
198
|
-
if prompt_session is not None:
|
|
199
|
-
try:
|
|
200
|
-
# PromptSession.prompt() can handle Ctrl+C gracefully
|
|
201
|
-
return await prompt_session.prompt_async(input_prompt)
|
|
202
|
-
except KeyboardInterrupt:
|
|
203
|
-
logger.debug("[permissions] KeyboardInterrupt in prompt_session")
|
|
204
|
-
return "n"
|
|
205
|
-
except EOFError:
|
|
206
|
-
logger.debug("[permissions] EOFError in prompt_session")
|
|
207
|
-
return "n"
|
|
161
|
+
async def _prompt_user(prompt: str, options: list[tuple[str, str]], is_html: bool = False) -> str:
|
|
162
|
+
"""Prompt the user with proper interrupt handling using choice().
|
|
208
163
|
|
|
209
|
-
|
|
164
|
+
Args:
|
|
165
|
+
prompt: The prompt text to display.
|
|
166
|
+
options: List of (value, label) tuples for choices.
|
|
167
|
+
is_html: If True, prompt is already formatted HTML and should not be escaped.
|
|
168
|
+
"""
|
|
210
169
|
loop = asyncio.get_running_loop()
|
|
211
|
-
responder = prompt_fn or input
|
|
212
170
|
|
|
213
171
|
def _ask() -> str:
|
|
214
172
|
try:
|
|
215
|
-
|
|
216
|
-
|
|
173
|
+
# If a custom prompt_fn is provided (e.g., for tests), use it directly
|
|
174
|
+
responder = prompt_fn or None
|
|
175
|
+
if responder is not None:
|
|
176
|
+
# Build a simple text prompt for the prompt_fn
|
|
177
|
+
numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
|
|
178
|
+
shortcut_choices = "/".join(opt[0] for opt in options)
|
|
179
|
+
input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
|
|
180
|
+
return responder(input_prompt)
|
|
181
|
+
|
|
182
|
+
# Convert options to choice() format (value, label)
|
|
183
|
+
# Labels can be HTML-formatted strings
|
|
184
|
+
choice_options = [(value, HTML(label) if "<" in label else label) for value, label in options]
|
|
185
|
+
|
|
186
|
+
# Build formatted message with prompt text
|
|
187
|
+
# Add visual separation with lines
|
|
188
|
+
if is_html:
|
|
189
|
+
# Prompt is already HTML formatted
|
|
190
|
+
formatted_prompt = HTML(f"\n{prompt}\n")
|
|
191
|
+
else:
|
|
192
|
+
# Escape HTML special characters in plain text prompt
|
|
193
|
+
formatted_prompt = HTML(f"\n{html.escape(prompt)}\n")
|
|
194
|
+
|
|
195
|
+
esc_bindings = KeyBindings()
|
|
196
|
+
|
|
197
|
+
@esc_bindings.add("escape", eager=True)
|
|
198
|
+
def _esc_to_deny(event: Any) -> None:
|
|
199
|
+
event.app.exit(result="n", style="class:aborting")
|
|
200
|
+
|
|
201
|
+
result = choice(
|
|
202
|
+
message=formatted_prompt,
|
|
203
|
+
options=choice_options,
|
|
204
|
+
style=_permission_style(),
|
|
205
|
+
show_frame=~is_done, # Frame disappears after selection
|
|
206
|
+
key_bindings=esc_bindings,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Clear the entire prompt after selection
|
|
210
|
+
# ANSI codes: ESC[F = move cursor to beginning of previous line
|
|
211
|
+
# ESC[2K = clear entire line
|
|
212
|
+
# We need to clear: blank + prompt + blank + options (each option takes 1 line)
|
|
213
|
+
# plus frame borders (top and bottom) = approximately 6-8 lines
|
|
214
|
+
for _ in range(12): # Clear enough lines to cover the prompt
|
|
215
|
+
print("\033[F\033[2K", end="", flush=True)
|
|
216
|
+
|
|
217
|
+
return result
|
|
218
|
+
except KeyboardInterrupt:
|
|
219
|
+
logger.debug("[permissions] KeyboardInterrupt in choice")
|
|
220
|
+
return "n"
|
|
221
|
+
except EOFError:
|
|
222
|
+
logger.debug("[permissions] EOFError in choice")
|
|
217
223
|
return "n"
|
|
218
224
|
|
|
219
225
|
return await loop.run_in_executor(None, _ask)
|
|
@@ -387,23 +393,24 @@ def make_permission_checker(
|
|
|
387
393
|
)
|
|
388
394
|
|
|
389
395
|
input_preview = _format_input_preview(parsed_input, tool_name=tool.name)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
396
|
+
# Use inline styles for prompt_toolkit HTML formatting
|
|
397
|
+
# The style names must match keys in the _permission_style() dict
|
|
398
|
+
prompt_html = f"""<title>{html.escape(tool.name)}</title>
|
|
399
|
+
|
|
400
|
+
<description>{input_preview}</description>"""
|
|
395
401
|
if decision.message:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
# Use warning style for warning messages
|
|
403
|
+
prompt_html += f"\n <warning>{html.escape(decision.message)}</warning>"
|
|
404
|
+
prompt_html += "\n <question>Do you want to proceed?</question>"
|
|
405
|
+
prompt = prompt_html
|
|
399
406
|
|
|
400
407
|
options = [
|
|
401
|
-
("y", "Yes"),
|
|
402
|
-
("s", "Yes, for this session"),
|
|
403
|
-
("n", "No"),
|
|
408
|
+
("y", "<yes-option>Yes</yes-option>"),
|
|
409
|
+
("s", "<yes-option>Yes, for this session</yes-option>"),
|
|
410
|
+
("n", "<no-option>No</no-option>"),
|
|
404
411
|
]
|
|
405
412
|
|
|
406
|
-
answer = (await _prompt_user(prompt, options=options)).strip().lower()
|
|
413
|
+
answer = (await _prompt_user(prompt, options=options, is_html=True)).strip().lower()
|
|
407
414
|
logger.debug(
|
|
408
415
|
"[permissions] User answer for permission prompt",
|
|
409
416
|
extra={"answer": answer, "tool": getattr(tool, "name", None)},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
ripperdoc/__init__.py,sha256=
|
|
1
|
+
ripperdoc/__init__.py,sha256=WpPS91f5Gza7_d_r4nyqaZ8bJMFXt486w-h3D1Ha9qk,66
|
|
2
2
|
ripperdoc/__main__.py,sha256=1Avq2MceBfwUlNsfasC8n4dqVL_V56Bl3DRsnY4_Nxk,370
|
|
3
3
|
ripperdoc/cli/__init__.py,sha256=03wf6gXBcEgXJrDJS-W_5BEG_DdJ_ep7CxQFPML-73g,35
|
|
4
4
|
ripperdoc/cli/cli.py,sha256=OZgN803jYmzdKxynHWG7VNwd7dQhM1KdYakXDyMlZT8,21826
|
|
@@ -30,11 +30,11 @@ ripperdoc/cli/ui/__init__.py,sha256=TxSzTYdITlrYmYVfins_w_jzPqqWRpqky5u1ikwvmtM,
|
|
|
30
30
|
ripperdoc/cli/ui/context_display.py,sha256=3ezdtHVwltkPQ5etYwfqUh-fjnpPu8B3P81UzrdHxZs,10020
|
|
31
31
|
ripperdoc/cli/ui/file_mention_completer.py,sha256=ysNqZieQVlUb7DC4CP_FMKNeTG8AIqKLmJgl6HJBURo,13649
|
|
32
32
|
ripperdoc/cli/ui/helpers.py,sha256=iM7kMb-fMTO6n4_MDVrESE1P-Y7w8PXiIhCCLp5yyA4,2618
|
|
33
|
-
ripperdoc/cli/ui/
|
|
34
|
-
ripperdoc/cli/ui/message_display.py,sha256=
|
|
33
|
+
ripperdoc/cli/ui/interrupt_listener.py,sha256=m9NeyOSJ3f-zuLE4YzUUEoKuuxo7oBKuvY5Z1WabjoE,7223
|
|
34
|
+
ripperdoc/cli/ui/message_display.py,sha256=eaS80PBo9QfgWT-MX4jfQOjbt0vwQ4w1MDGKBWPrQOs,10856
|
|
35
35
|
ripperdoc/cli/ui/panels.py,sha256=kaf3sDG2AIcrdrqbASnqGYHySfSeMNozGFhqvZ4bTJs,2207
|
|
36
36
|
ripperdoc/cli/ui/provider_options.py,sha256=Ic30K1ev8w2oEcha4IjDYSoxw1tyCVB4hLoQpS_Y_5E,8369
|
|
37
|
-
ripperdoc/cli/ui/rich_ui.py,sha256=
|
|
37
|
+
ripperdoc/cli/ui/rich_ui.py,sha256=LkE22XdNE4sooX-SyVUec7qX_XDTHjExFTwhqj1MPY4,76870
|
|
38
38
|
ripperdoc/cli/ui/spinner.py,sha256=IlmMgyk-eA6Bk4fXDjbWs3AFL2iFPeWmnS0q28tWOgU,5175
|
|
39
39
|
ripperdoc/cli/ui/thinking_spinner.py,sha256=3zmxj3vd-1njdiHF2Afsm7RGiRl7645AEc7fTLKgAbU,2805
|
|
40
40
|
ripperdoc/cli/ui/tool_renderers.py,sha256=7ACcZoKU91kvFsk3YdP2TzibfI-W_F1dv-Ksr68rrYE,11280
|
|
@@ -45,7 +45,7 @@ ripperdoc/core/commands.py,sha256=NXCkljYbAP4dDoRy-_3semFNWxG4YAk9q82u8FTKH60,83
|
|
|
45
45
|
ripperdoc/core/config.py,sha256=4KvaGKG4bf9wX6haPvxJllokdpPbJVvfTIMfsC0maag,29936
|
|
46
46
|
ripperdoc/core/custom_commands.py,sha256=2BMYiBq15FDjl3aOa3styN6nARyfU7xFAb4ly2Vsp-w,14254
|
|
47
47
|
ripperdoc/core/default_tools.py,sha256=mpb9mqmrAjMqgcmpRU_uQF1G6vpYbp8nkiwrh71KQyM,5250
|
|
48
|
-
ripperdoc/core/permissions.py,sha256=
|
|
48
|
+
ripperdoc/core/permissions.py,sha256=7Fj44HB4yJj5bm54T_c85sdzjMFWWg_5kXm3CZqklI4,18424
|
|
49
49
|
ripperdoc/core/query.py,sha256=dC_sW2D2bPLI6nPM3VxFpTBA4fYYHHRNmE6zvy93pus,62057
|
|
50
50
|
ripperdoc/core/query_utils.py,sha256=2OQYeobFD-24NG0CZnnm3D5HCJ7r8AOB08I-wpQhH1A,24888
|
|
51
51
|
ripperdoc/core/skills.py,sha256=NGP_QFzL2SBkz8wZBGxNA_6Gt8qkRIZtJDnPs_DayxE,10511
|
|
@@ -128,9 +128,9 @@ ripperdoc/utils/permissions/__init__.py,sha256=33FfOaDLepxJSkp0RLvTdVu7qBXuEcnOo
|
|
|
128
128
|
ripperdoc/utils/permissions/path_validation_utils.py,sha256=KOegjWaph8tXU7aqwQXRAxFEzrmRuPvdLb36J1QIPDQ,5772
|
|
129
129
|
ripperdoc/utils/permissions/shell_command_validation.py,sha256=BRi-1OGzr-Wiuxa00Ye6oqPctoJUZBOoRoi6dUiAsfM,33256
|
|
130
130
|
ripperdoc/utils/permissions/tool_permission_utils.py,sha256=YqbYSXyR0RBbLibBtigYb9O9143mDRulsQCh_WwArvY,14653
|
|
131
|
-
ripperdoc-0.3.
|
|
132
|
-
ripperdoc-0.3.
|
|
133
|
-
ripperdoc-0.3.
|
|
134
|
-
ripperdoc-0.3.
|
|
135
|
-
ripperdoc-0.3.
|
|
136
|
-
ripperdoc-0.3.
|
|
131
|
+
ripperdoc-0.3.1.dist-info/licenses/LICENSE,sha256=bRv9UhBor6GhnQDj12RciDcRfu0R7sB-lqCy1sWF75c,9242
|
|
132
|
+
ripperdoc-0.3.1.dist-info/METADATA,sha256=QawsGroIo9Rs4CodXPVMBNbiFmPZrnJE8nMgqNNDEyQ,8651
|
|
133
|
+
ripperdoc-0.3.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
134
|
+
ripperdoc-0.3.1.dist-info/entry_points.txt,sha256=79aohFxFPJmrQ3-Mhain04vb3EWpuc0EyzvDDUnwAu4,81
|
|
135
|
+
ripperdoc-0.3.1.dist-info/top_level.txt,sha256=u8LbdTr1a-laHgCO0Utl_R3QGFUhLxWelCDnP2ZgpCU,10
|
|
136
|
+
ripperdoc-0.3.1.dist-info/RECORD,,
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
"""Interrupt handling for RichUI.
|
|
2
|
-
|
|
3
|
-
This module handles ESC/Ctrl+C key detection during query execution,
|
|
4
|
-
including terminal raw mode management.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import asyncio
|
|
8
|
-
import contextlib
|
|
9
|
-
import sys
|
|
10
|
-
from typing import Any, Optional, Set
|
|
11
|
-
|
|
12
|
-
from ripperdoc.utils.log import get_logger
|
|
13
|
-
from ripperdoc.utils.platform import is_windows
|
|
14
|
-
|
|
15
|
-
logger = get_logger()
|
|
16
|
-
|
|
17
|
-
# Keys that trigger interrupt
|
|
18
|
-
INTERRUPT_KEYS: Set[str] = {"\x1b", "\x03"} # ESC, Ctrl+C
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class InterruptHandler:
|
|
22
|
-
"""Handles keyboard interrupt detection during async operations."""
|
|
23
|
-
|
|
24
|
-
def __init__(self) -> None:
|
|
25
|
-
"""Initialize the interrupt handler."""
|
|
26
|
-
self._query_interrupted: bool = False
|
|
27
|
-
self._esc_listener_active: bool = False
|
|
28
|
-
self._esc_listener_paused: bool = False
|
|
29
|
-
self._stdin_fd: Optional[int] = None
|
|
30
|
-
self._stdin_old_settings: Optional[list] = None
|
|
31
|
-
self._stdin_in_raw_mode: bool = False
|
|
32
|
-
self._abort_callback: Optional[Any] = None
|
|
33
|
-
|
|
34
|
-
def set_abort_callback(self, callback: Any) -> None:
|
|
35
|
-
"""Set the callback to trigger when interrupt is detected."""
|
|
36
|
-
self._abort_callback = callback
|
|
37
|
-
|
|
38
|
-
@property
|
|
39
|
-
def was_interrupted(self) -> bool:
|
|
40
|
-
"""Check if the last query was interrupted."""
|
|
41
|
-
return self._query_interrupted
|
|
42
|
-
|
|
43
|
-
def pause_listener(self) -> bool:
|
|
44
|
-
"""Pause ESC listener and restore cooked terminal mode if we own raw mode.
|
|
45
|
-
|
|
46
|
-
Returns:
|
|
47
|
-
Previous paused state for later restoration.
|
|
48
|
-
"""
|
|
49
|
-
prev = self._esc_listener_paused
|
|
50
|
-
self._esc_listener_paused = True
|
|
51
|
-
|
|
52
|
-
# Windows doesn't support termios
|
|
53
|
-
if is_windows():
|
|
54
|
-
return prev
|
|
55
|
-
|
|
56
|
-
try:
|
|
57
|
-
import termios
|
|
58
|
-
except ImportError:
|
|
59
|
-
return prev
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
self._stdin_fd is not None
|
|
63
|
-
and self._stdin_old_settings is not None
|
|
64
|
-
and self._stdin_in_raw_mode
|
|
65
|
-
):
|
|
66
|
-
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
67
|
-
termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._stdin_old_settings)
|
|
68
|
-
self._stdin_in_raw_mode = False
|
|
69
|
-
return prev
|
|
70
|
-
|
|
71
|
-
def resume_listener(self, previous_state: bool) -> None:
|
|
72
|
-
"""Restore paused state to what it was before a blocking prompt."""
|
|
73
|
-
self._esc_listener_paused = previous_state
|
|
74
|
-
|
|
75
|
-
async def _listen_for_interrupt_key(self) -> bool:
|
|
76
|
-
"""Listen for interrupt keys (ESC/Ctrl+C) during query execution.
|
|
77
|
-
|
|
78
|
-
Uses raw terminal mode for immediate key detection without waiting
|
|
79
|
-
for escape sequences to complete.
|
|
80
|
-
|
|
81
|
-
Returns:
|
|
82
|
-
True if an interrupt key was pressed.
|
|
83
|
-
"""
|
|
84
|
-
if is_windows():
|
|
85
|
-
# Windows: use msvcrt for non-blocking key detection
|
|
86
|
-
try:
|
|
87
|
-
import msvcrt
|
|
88
|
-
except ImportError:
|
|
89
|
-
# Fallback: just wait - Ctrl+C is handled by OS
|
|
90
|
-
while self._esc_listener_active:
|
|
91
|
-
await asyncio.sleep(0.1)
|
|
92
|
-
return False
|
|
93
|
-
|
|
94
|
-
while self._esc_listener_active:
|
|
95
|
-
if self._esc_listener_paused:
|
|
96
|
-
await asyncio.sleep(0.05)
|
|
97
|
-
continue
|
|
98
|
-
|
|
99
|
-
# Check for key press in a thread to avoid blocking
|
|
100
|
-
def check_key() -> Optional[str]:
|
|
101
|
-
if msvcrt.kbhit(): # type: ignore[attr-defined]
|
|
102
|
-
return msvcrt.getch().decode("latin-1") # type: ignore[attr-defined,no-any-return]
|
|
103
|
-
return None
|
|
104
|
-
|
|
105
|
-
key = await asyncio.to_thread(check_key)
|
|
106
|
-
if key in INTERRUPT_KEYS:
|
|
107
|
-
return True
|
|
108
|
-
|
|
109
|
-
await asyncio.sleep(0.02)
|
|
110
|
-
return False
|
|
111
|
-
|
|
112
|
-
import select
|
|
113
|
-
import termios
|
|
114
|
-
import tty
|
|
115
|
-
|
|
116
|
-
try:
|
|
117
|
-
fd = sys.stdin.fileno()
|
|
118
|
-
old_settings = termios.tcgetattr(fd)
|
|
119
|
-
except (OSError, termios.error, ValueError):
|
|
120
|
-
return False
|
|
121
|
-
|
|
122
|
-
self._stdin_fd = fd
|
|
123
|
-
self._stdin_old_settings = old_settings
|
|
124
|
-
raw_enabled = False
|
|
125
|
-
try:
|
|
126
|
-
while self._esc_listener_active:
|
|
127
|
-
if self._esc_listener_paused:
|
|
128
|
-
if raw_enabled:
|
|
129
|
-
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
130
|
-
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
131
|
-
raw_enabled = False
|
|
132
|
-
self._stdin_in_raw_mode = False
|
|
133
|
-
await asyncio.sleep(0.05)
|
|
134
|
-
continue
|
|
135
|
-
|
|
136
|
-
if not raw_enabled:
|
|
137
|
-
tty.setraw(fd)
|
|
138
|
-
raw_enabled = True
|
|
139
|
-
self._stdin_in_raw_mode = True
|
|
140
|
-
|
|
141
|
-
await asyncio.sleep(0.02)
|
|
142
|
-
if select.select([sys.stdin], [], [], 0)[0]:
|
|
143
|
-
if sys.stdin.read(1) in INTERRUPT_KEYS:
|
|
144
|
-
return True
|
|
145
|
-
except (OSError, ValueError):
|
|
146
|
-
pass
|
|
147
|
-
finally:
|
|
148
|
-
self._stdin_in_raw_mode = False
|
|
149
|
-
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
150
|
-
if raw_enabled:
|
|
151
|
-
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
152
|
-
self._stdin_fd = None
|
|
153
|
-
self._stdin_old_settings = None
|
|
154
|
-
|
|
155
|
-
return False
|
|
156
|
-
|
|
157
|
-
async def _cancel_task(self, task: asyncio.Task) -> None:
|
|
158
|
-
"""Cancel a task and wait for it to finish."""
|
|
159
|
-
if not task.done():
|
|
160
|
-
task.cancel()
|
|
161
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
162
|
-
await task
|
|
163
|
-
|
|
164
|
-
def _trigger_abort(self) -> None:
|
|
165
|
-
"""Signal the query to abort via callback."""
|
|
166
|
-
if self._abort_callback is not None:
|
|
167
|
-
self._abort_callback()
|
|
168
|
-
|
|
169
|
-
async def run_with_interrupt(self, query_coro: Any) -> bool:
|
|
170
|
-
"""Run a coroutine with ESC key interrupt support.
|
|
171
|
-
|
|
172
|
-
Args:
|
|
173
|
-
query_coro: The coroutine to run with interrupt support.
|
|
174
|
-
|
|
175
|
-
Returns:
|
|
176
|
-
True if interrupted, False if completed normally.
|
|
177
|
-
"""
|
|
178
|
-
self._query_interrupted = False
|
|
179
|
-
self._esc_listener_active = True
|
|
180
|
-
|
|
181
|
-
query_task = asyncio.create_task(query_coro)
|
|
182
|
-
interrupt_task = asyncio.create_task(self._listen_for_interrupt_key())
|
|
183
|
-
|
|
184
|
-
try:
|
|
185
|
-
done, _ = await asyncio.wait(
|
|
186
|
-
{query_task, interrupt_task}, return_when=asyncio.FIRST_COMPLETED
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
# Check if interrupted
|
|
190
|
-
if interrupt_task in done and interrupt_task.result():
|
|
191
|
-
self._query_interrupted = True
|
|
192
|
-
self._trigger_abort()
|
|
193
|
-
await self._cancel_task(query_task)
|
|
194
|
-
return True
|
|
195
|
-
|
|
196
|
-
# Query completed normally
|
|
197
|
-
if query_task in done:
|
|
198
|
-
await self._cancel_task(interrupt_task)
|
|
199
|
-
with contextlib.suppress(Exception):
|
|
200
|
-
query_task.result()
|
|
201
|
-
return False
|
|
202
|
-
|
|
203
|
-
return False
|
|
204
|
-
|
|
205
|
-
finally:
|
|
206
|
-
self._esc_listener_active = False
|
|
207
|
-
await self._cancel_task(query_task)
|
|
208
|
-
await self._cancel_task(interrupt_task)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|