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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """Ripperdoc - AI-powered coding agent."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -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."""
@@ -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.interrupt_handler import InterruptHandler
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
- spinner.stop()
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
- spinner.start()
1163
- spinner.update("Thinking...")
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
- spinner.stop()
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
- self._resume_interrupt_listener(was_paused)
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
- # Delegate to InterruptHandler
1283
- def _pause_interrupt_listener(self) -> bool:
1284
- return self._interrupt_handler.pause_listener()
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 _resume_interrupt_listener(self, previous_state: bool) -> None:
1287
- self._interrupt_handler.resume_listener(previous_state)
1321
+ def _start_interrupt_listener(self) -> None:
1322
+ self._interrupt_listener.start()
1288
1323
 
1289
- def _trigger_abort(self) -> None:
1290
- """Signal the query to abort."""
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
- async def _run_query_with_esc_interrupt(self, query_coro: Any) -> bool:
1295
- """Run a query with ESC key interrupt support."""
1296
- return await self._interrupt_handler.run_with_interrupt(query_coro)
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
- # Use _run_async instead of _run_async_with_esc_interrupt for piped stdin
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
- # When using /dev/tty input, disable ESC interrupt to avoid conflicts
1618
- if self._using_tty_input:
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
 
@@ -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
- lines = [f"Command: {getattr(parsed_input, 'command')}"]
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: {parsed_input.timeout}ms")
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: {parsed_input.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: {parsed_input.run_in_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: {parsed_input.shell_executable}")
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='{getattr(parsed_input, '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='{getattr(parsed_input, 'file_path')}'"
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='{getattr(parsed_input, '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
- return preview[:137] + "..."
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 _render_options_prompt(prompt: str, options: list[tuple[str, str]]) -> str:
96
- """Render a simple numbered prompt (fallback for non-Rich environments)."""
97
- border = "─" * 120
98
- lines = [border, prompt, ""]
99
- for idx, (_, label) in enumerate(options, start=1):
100
- prefix = "" if idx == 1 else " "
101
- lines.append(f"{prefix} {idx}. {label}")
102
- numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
103
- shortcut_choices = "/".join(opt[0] for opt in options)
104
- lines.append(f"Choice ({numeric_choices} or {shortcut_choices}): ")
105
- return "\n".join(lines)
106
-
107
-
108
- def _render_options_prompt_rich(
109
- console: "Console",
110
- prompt: str,
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
- # Fallback to simple input() via executor
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
- return responder(input_prompt)
216
- except (KeyboardInterrupt, EOFError):
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
- prompt_lines = [
391
- f"{tool.name}",
392
- "",
393
- f" {input_preview}",
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
- prompt_lines.append(f" {decision.message}")
397
- prompt_lines.append(" Do you want to proceed?")
398
- prompt = "\n".join(prompt_lines)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ripperdoc
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: AI-powered terminal assistant for coding tasks
5
5
  Author: Ripperdoc Team
6
6
  License: Apache-2.0
@@ -1,4 +1,4 @@
1
- ripperdoc/__init__.py,sha256=r9wkvo_s7lhTfuQG7WVFeE3Eg4HgMGHovnyZR1OVnPA,66
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/interrupt_handler.py,sha256=6MwZ2wo1ZCdd4JGV5dWOzHkNDDce51z9Rhf0LdFjWTM,7107
34
- ripperdoc/cli/ui/message_display.py,sha256=W9VlRCpMykkamxphej5LHgmU-P4YaBfm9HPdIKlFKpg,10573
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=LZjmaCSfXf8Le5zq03e4PH2S0nlsSXW566q7mW-xWaA,76968
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=yp7OK36_I_UceDnLbGmxPh6nSbIDfns4IgTVGH5Tkqs,16986
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.0.dist-info/licenses/LICENSE,sha256=bRv9UhBor6GhnQDj12RciDcRfu0R7sB-lqCy1sWF75c,9242
132
- ripperdoc-0.3.0.dist-info/METADATA,sha256=fZifh_w_rlHCJHbkadgF5_g3oDi7bH_7M_qekoC4g1Y,8651
133
- ripperdoc-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
134
- ripperdoc-0.3.0.dist-info/entry_points.txt,sha256=79aohFxFPJmrQ3-Mhain04vb3EWpuc0EyzvDDUnwAu4,81
135
- ripperdoc-0.3.0.dist-info/top_level.txt,sha256=u8LbdTr1a-laHgCO0Utl_R3QGFUhLxWelCDnP2ZgpCU,10
136
- ripperdoc-0.3.0.dist-info/RECORD,,
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)