ripperdoc 0.2.5__tar.gz → 0.2.6__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.
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/PKG-INFO +1 -1
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/__init__.py +1 -1
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/rich_ui.py +161 -3
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/query.py +12 -1
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/tool.py +5 -3
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/bash_tool.py +20 -8
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/file_edit_tool.py +4 -2
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/file_read_tool.py +3 -1
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/file_write_tool.py +4 -2
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/notebook_edit_tool.py +4 -2
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/path_validation_utils.py +11 -10
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/PKG-INFO +1 -1
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_shell_permissions.py +18 -1
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/LICENSE +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/README.md +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/pyproject.toml +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/__main__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/cli.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/agents_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/base.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/clear_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/compact_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/config_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/context_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/cost_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/doctor_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/exit_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/help_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/mcp_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/memory_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/models_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/permissions_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/resume_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/status_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/tasks_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/todos_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/tools_cmd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/context_display.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/helpers.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/spinner.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/thinking_spinner.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/tool_renderers.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/agents.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/commands.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/config.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/default_tools.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/permissions.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/anthropic.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/base.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/gemini.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/openai.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/query_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/skills.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/system_prompt.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/sdk/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/sdk/client.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/ask_user_question_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/background_shell.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/bash_output_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/dynamic_mcp_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/enter_plan_mode_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/exit_plan_mode_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/glob_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/grep_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/kill_bash_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/ls_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/mcp_tools.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/multi_edit_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/skill_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/task_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/todo_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/tool_search_tool.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/bash_constants.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/bash_output_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/coerce.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/context_length_errors.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/exit_code_handlers.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/file_watch.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/git_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/json_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/log.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/mcp.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/memory.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/message_compaction.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/messages.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/output_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/path_ignore.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/path_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/__init__.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/shell_command_validation.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/tool_permission_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/prompt.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/safe_get_cwd.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/sandbox_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/session_history.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/session_usage.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/shell_token_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/shell_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/todo.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/token_estimation.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/SOURCES.txt +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/dependency_links.txt +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/entry_points.txt +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/requires.txt +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/top_level.txt +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/setup.cfg +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/setup.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_background_shell_shutdown.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_cli_commands.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_config.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_context_length_errors.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_context_limits.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_mcp_config.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_messages.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_output_utils.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_path_ignore.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_permissions.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_query_abort.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_sdk.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_skills.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_todo.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_tool_search.py +0 -0
- {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_tools.py +0 -0
|
@@ -4,6 +4,7 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
|
+
import contextlib
|
|
7
8
|
import json
|
|
8
9
|
import sys
|
|
9
10
|
import uuid
|
|
@@ -22,6 +23,7 @@ from prompt_toolkit import PromptSession
|
|
|
22
23
|
from prompt_toolkit.completion import Completer, Completion
|
|
23
24
|
from prompt_toolkit.shortcuts.prompt import CompleteStyle
|
|
24
25
|
from prompt_toolkit.history import InMemoryHistory
|
|
26
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
25
27
|
|
|
26
28
|
from ripperdoc import __version__
|
|
27
29
|
from ripperdoc.core.config import get_global_config, provider_protocol
|
|
@@ -83,7 +85,6 @@ THINKING_WORDS: list[str] = [
|
|
|
83
85
|
"Cerebrating",
|
|
84
86
|
"Channelling",
|
|
85
87
|
"Churning",
|
|
86
|
-
"Clauding",
|
|
87
88
|
"Coalescing",
|
|
88
89
|
"Cogitating",
|
|
89
90
|
"Computing",
|
|
@@ -226,6 +227,12 @@ class RichUI:
|
|
|
226
227
|
self.query_context: Optional[QueryContext] = None
|
|
227
228
|
self._current_tool: Optional[str] = None
|
|
228
229
|
self._should_exit: bool = False
|
|
230
|
+
self._query_interrupted: bool = False # Track if query was interrupted by ESC
|
|
231
|
+
self._esc_listener_active: bool = False # Track if ESC listener is active
|
|
232
|
+
self._esc_listener_paused: bool = False # Pause ESC listener during blocking prompts
|
|
233
|
+
self._stdin_fd: Optional[int] = None # Track stdin for raw mode restoration
|
|
234
|
+
self._stdin_old_settings: Optional[list] = None # Original terminal settings
|
|
235
|
+
self._stdin_in_raw_mode: bool = False # Whether we currently own raw mode
|
|
229
236
|
self.command_list = list_slash_commands()
|
|
230
237
|
self._command_completions = slash_command_completions()
|
|
231
238
|
self._prompt_session: Optional[PromptSession] = None
|
|
@@ -940,6 +947,7 @@ class RichUI:
|
|
|
940
947
|
|
|
941
948
|
async def permission_checker(tool: Any, parsed_input: Any) -> bool:
|
|
942
949
|
spinner.stop()
|
|
950
|
+
was_paused = self._pause_interrupt_listener()
|
|
943
951
|
try:
|
|
944
952
|
if base_permission_checker is not None:
|
|
945
953
|
result = await base_permission_checker(tool, parsed_input)
|
|
@@ -955,6 +963,7 @@ class RichUI:
|
|
|
955
963
|
return allowed
|
|
956
964
|
return True
|
|
957
965
|
finally:
|
|
966
|
+
self._resume_interrupt_listener(was_paused)
|
|
958
967
|
# Wrap spinner restart in try-except to prevent exceptions
|
|
959
968
|
# from discarding the permission result
|
|
960
969
|
try:
|
|
@@ -1037,6 +1046,138 @@ class RichUI:
|
|
|
1037
1046
|
)
|
|
1038
1047
|
self.display_message("System", f"Error: {str(exc)}", is_tool=True)
|
|
1039
1048
|
|
|
1049
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1050
|
+
# ESC Key Interrupt Support
|
|
1051
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1052
|
+
|
|
1053
|
+
# Keys that trigger interrupt
|
|
1054
|
+
_INTERRUPT_KEYS = {'\x1b', '\x03'} # ESC, Ctrl+C
|
|
1055
|
+
|
|
1056
|
+
def _pause_interrupt_listener(self) -> bool:
|
|
1057
|
+
"""Pause ESC listener and restore cooked terminal mode if we own raw mode."""
|
|
1058
|
+
prev = self._esc_listener_paused
|
|
1059
|
+
self._esc_listener_paused = True
|
|
1060
|
+
try:
|
|
1061
|
+
import termios
|
|
1062
|
+
except ImportError:
|
|
1063
|
+
return prev
|
|
1064
|
+
|
|
1065
|
+
if (
|
|
1066
|
+
self._stdin_fd is not None
|
|
1067
|
+
and self._stdin_old_settings is not None
|
|
1068
|
+
and self._stdin_in_raw_mode
|
|
1069
|
+
):
|
|
1070
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
1071
|
+
termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._stdin_old_settings)
|
|
1072
|
+
self._stdin_in_raw_mode = False
|
|
1073
|
+
return prev
|
|
1074
|
+
|
|
1075
|
+
def _resume_interrupt_listener(self, previous_state: bool) -> None:
|
|
1076
|
+
"""Restore paused state to what it was before a blocking prompt."""
|
|
1077
|
+
self._esc_listener_paused = previous_state
|
|
1078
|
+
|
|
1079
|
+
async def _listen_for_interrupt_key(self) -> bool:
|
|
1080
|
+
"""Listen for interrupt keys (ESC/Ctrl+C) during query execution.
|
|
1081
|
+
|
|
1082
|
+
Uses raw terminal mode for immediate key detection without waiting
|
|
1083
|
+
for escape sequences to complete.
|
|
1084
|
+
"""
|
|
1085
|
+
import sys
|
|
1086
|
+
import select
|
|
1087
|
+
import termios
|
|
1088
|
+
import tty
|
|
1089
|
+
|
|
1090
|
+
try:
|
|
1091
|
+
fd = sys.stdin.fileno()
|
|
1092
|
+
old_settings = termios.tcgetattr(fd)
|
|
1093
|
+
except (OSError, termios.error, ValueError):
|
|
1094
|
+
return False
|
|
1095
|
+
|
|
1096
|
+
self._stdin_fd = fd
|
|
1097
|
+
self._stdin_old_settings = old_settings
|
|
1098
|
+
raw_enabled = False
|
|
1099
|
+
try:
|
|
1100
|
+
while self._esc_listener_active:
|
|
1101
|
+
if self._esc_listener_paused:
|
|
1102
|
+
if raw_enabled:
|
|
1103
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
1104
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
1105
|
+
raw_enabled = False
|
|
1106
|
+
self._stdin_in_raw_mode = False
|
|
1107
|
+
await asyncio.sleep(0.05)
|
|
1108
|
+
continue
|
|
1109
|
+
|
|
1110
|
+
if not raw_enabled:
|
|
1111
|
+
tty.setraw(fd)
|
|
1112
|
+
raw_enabled = True
|
|
1113
|
+
self._stdin_in_raw_mode = True
|
|
1114
|
+
|
|
1115
|
+
await asyncio.sleep(0.02)
|
|
1116
|
+
if select.select([sys.stdin], [], [], 0)[0]:
|
|
1117
|
+
if sys.stdin.read(1) in self._INTERRUPT_KEYS:
|
|
1118
|
+
return True
|
|
1119
|
+
except (OSError, ValueError):
|
|
1120
|
+
pass
|
|
1121
|
+
finally:
|
|
1122
|
+
self._stdin_in_raw_mode = False
|
|
1123
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
1124
|
+
if raw_enabled:
|
|
1125
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
1126
|
+
self._stdin_fd = None
|
|
1127
|
+
self._stdin_old_settings = None
|
|
1128
|
+
|
|
1129
|
+
return False
|
|
1130
|
+
|
|
1131
|
+
async def _cancel_task(self, task: asyncio.Task) -> None:
|
|
1132
|
+
"""Cancel a task and wait for it to finish."""
|
|
1133
|
+
if not task.done():
|
|
1134
|
+
task.cancel()
|
|
1135
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1136
|
+
await task
|
|
1137
|
+
|
|
1138
|
+
def _trigger_abort(self) -> None:
|
|
1139
|
+
"""Signal the query to abort."""
|
|
1140
|
+
if self.query_context and hasattr(self.query_context, "abort_controller"):
|
|
1141
|
+
self.query_context.abort_controller.set()
|
|
1142
|
+
|
|
1143
|
+
async def _run_query_with_esc_interrupt(self, query_coro: Any) -> bool:
|
|
1144
|
+
"""Run a query with ESC key interrupt support.
|
|
1145
|
+
|
|
1146
|
+
Returns True if interrupted, False if completed normally.
|
|
1147
|
+
"""
|
|
1148
|
+
self._query_interrupted = False
|
|
1149
|
+
self._esc_listener_active = True
|
|
1150
|
+
|
|
1151
|
+
query_task = asyncio.create_task(query_coro)
|
|
1152
|
+
interrupt_task = asyncio.create_task(self._listen_for_interrupt_key())
|
|
1153
|
+
|
|
1154
|
+
try:
|
|
1155
|
+
done, _ = await asyncio.wait(
|
|
1156
|
+
{query_task, interrupt_task},
|
|
1157
|
+
return_when=asyncio.FIRST_COMPLETED
|
|
1158
|
+
)
|
|
1159
|
+
|
|
1160
|
+
# Check if interrupted
|
|
1161
|
+
if interrupt_task in done and interrupt_task.result():
|
|
1162
|
+
self._query_interrupted = True
|
|
1163
|
+
self._trigger_abort()
|
|
1164
|
+
await self._cancel_task(query_task)
|
|
1165
|
+
return True
|
|
1166
|
+
|
|
1167
|
+
# Query completed normally
|
|
1168
|
+
if query_task in done:
|
|
1169
|
+
await self._cancel_task(interrupt_task)
|
|
1170
|
+
with contextlib.suppress(Exception):
|
|
1171
|
+
query_task.result()
|
|
1172
|
+
return False
|
|
1173
|
+
|
|
1174
|
+
return False
|
|
1175
|
+
|
|
1176
|
+
finally:
|
|
1177
|
+
self._esc_listener_active = False
|
|
1178
|
+
await self._cancel_task(query_task)
|
|
1179
|
+
await self._cancel_task(interrupt_task)
|
|
1180
|
+
|
|
1040
1181
|
def _run_async(self, coro: Any) -> Any:
|
|
1041
1182
|
"""Run a coroutine on the persistent event loop."""
|
|
1042
1183
|
if self._loop.is_closed():
|
|
@@ -1044,6 +1185,16 @@ class RichUI:
|
|
|
1044
1185
|
asyncio.set_event_loop(self._loop)
|
|
1045
1186
|
return self._loop.run_until_complete(coro)
|
|
1046
1187
|
|
|
1188
|
+
def _run_async_with_esc_interrupt(self, coro: Any) -> bool:
|
|
1189
|
+
"""Run a coroutine with ESC key interrupt support.
|
|
1190
|
+
|
|
1191
|
+
Returns True if interrupted by ESC, False if completed normally.
|
|
1192
|
+
"""
|
|
1193
|
+
if self._loop.is_closed():
|
|
1194
|
+
self._loop = asyncio.new_event_loop()
|
|
1195
|
+
asyncio.set_event_loop(self._loop)
|
|
1196
|
+
return self._loop.run_until_complete(self._run_query_with_esc_interrupt(coro))
|
|
1197
|
+
|
|
1047
1198
|
def run_async(self, coro: Any) -> Any:
|
|
1048
1199
|
"""Public wrapper for running coroutines on the UI event loop."""
|
|
1049
1200
|
return self._run_async(coro)
|
|
@@ -1111,7 +1262,7 @@ class RichUI:
|
|
|
1111
1262
|
# Display status
|
|
1112
1263
|
console.print(create_status_bar())
|
|
1113
1264
|
console.print()
|
|
1114
|
-
console.print("[dim]Tip: type '/' then press Tab to see available commands.[/dim]\n")
|
|
1265
|
+
console.print("[dim]Tip: type '/' then press Tab to see available commands. Press ESC to interrupt a running query.[/dim]\n")
|
|
1115
1266
|
|
|
1116
1267
|
session = self.get_prompt_session()
|
|
1117
1268
|
logger.info(
|
|
@@ -1155,7 +1306,14 @@ class RichUI:
|
|
|
1155
1306
|
"prompt_preview": user_input[:200],
|
|
1156
1307
|
},
|
|
1157
1308
|
)
|
|
1158
|
-
self.
|
|
1309
|
+
interrupted = self._run_async_with_esc_interrupt(self.process_query(user_input))
|
|
1310
|
+
|
|
1311
|
+
if interrupted:
|
|
1312
|
+
console.print("\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]")
|
|
1313
|
+
logger.info(
|
|
1314
|
+
"[ui] Query interrupted by ESC key",
|
|
1315
|
+
extra={"session_id": self.session_id},
|
|
1316
|
+
)
|
|
1159
1317
|
|
|
1160
1318
|
console.print() # Add spacing between interactions
|
|
1161
1319
|
|
|
@@ -774,9 +774,20 @@ async def _run_query_iteration(
|
|
|
774
774
|
progress = progress_queue.get_nowait()
|
|
775
775
|
except asyncio.QueueEmpty:
|
|
776
776
|
waiter = asyncio.create_task(progress_queue.get())
|
|
777
|
+
# Use timeout to periodically check abort_controller during LLM request
|
|
777
778
|
done, pending = await asyncio.wait(
|
|
778
|
-
{assistant_task, waiter},
|
|
779
|
+
{assistant_task, waiter},
|
|
780
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
781
|
+
timeout=0.1 # Check abort_controller every 100ms
|
|
779
782
|
)
|
|
783
|
+
if not done:
|
|
784
|
+
# Timeout - cancel waiter and continue loop to check abort_controller
|
|
785
|
+
waiter.cancel()
|
|
786
|
+
try:
|
|
787
|
+
await waiter
|
|
788
|
+
except asyncio.CancelledError:
|
|
789
|
+
pass
|
|
790
|
+
continue
|
|
780
791
|
if assistant_task in done:
|
|
781
792
|
for task in pending:
|
|
782
793
|
task.cancel()
|
|
@@ -6,8 +6,8 @@ Tools are the primary way that the AI agent interacts with the environment.
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
from abc import ABC, abstractmethod
|
|
9
|
-
from typing import Any, AsyncGenerator, Dict, List, Optional, TypeVar, Generic, Union
|
|
10
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, TypeVar, Generic, Union
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, SkipValidation
|
|
11
11
|
from ripperdoc.utils.file_watch import FileSnapshot
|
|
12
12
|
from ripperdoc.utils.log import get_logger
|
|
13
13
|
|
|
@@ -41,7 +41,9 @@ class ToolUseContext(BaseModel):
|
|
|
41
41
|
verbose: bool = False
|
|
42
42
|
permission_checker: Optional[Any] = None
|
|
43
43
|
read_file_timestamps: Dict[str, float] = Field(default_factory=dict)
|
|
44
|
-
|
|
44
|
+
# SkipValidation prevents Pydantic from copying the dict during validation,
|
|
45
|
+
# ensuring View/Read and Edit tools share the same cache instance
|
|
46
|
+
file_state_cache: Annotated[Dict[str, FileSnapshot], SkipValidation] = Field(default_factory=dict)
|
|
45
47
|
tool_registry: Optional[Any] = None
|
|
46
48
|
abort_signal: Optional[Any] = None
|
|
47
49
|
# UI control callbacks for tools that need user interaction
|
|
@@ -318,6 +318,24 @@ build projects, run tests, and interact with the file system."""
|
|
|
318
318
|
deny_rules = permission_context.get("denied_rules") or set()
|
|
319
319
|
allowed_dirs = permission_context.get("allowed_working_directories") or {safe_get_cwd()}
|
|
320
320
|
|
|
321
|
+
# Check for sensitive directory access with read-only commands (cd, find).
|
|
322
|
+
# These should ask for user confirmation rather than being blocked outright.
|
|
323
|
+
cwd = safe_get_cwd()
|
|
324
|
+
path_validation = validate_shell_command_paths(
|
|
325
|
+
input_data.command,
|
|
326
|
+
cwd,
|
|
327
|
+
allowed_dirs,
|
|
328
|
+
)
|
|
329
|
+
if path_validation.behavior == "ask":
|
|
330
|
+
# For read-only directory operations, ask user for confirmation
|
|
331
|
+
return PermissionDecision(
|
|
332
|
+
behavior="ask",
|
|
333
|
+
message=path_validation.message,
|
|
334
|
+
updated_input=input_data,
|
|
335
|
+
decision_reason={"type": "sensitive_directory_access"},
|
|
336
|
+
rule_suggestions=path_validation.rule_suggestions,
|
|
337
|
+
)
|
|
338
|
+
|
|
321
339
|
decision = evaluate_shell_command_permissions(
|
|
322
340
|
input_data,
|
|
323
341
|
allow_rules,
|
|
@@ -366,14 +384,8 @@ build projects, run tests, and interact with the file system."""
|
|
|
366
384
|
result=False, message="Sandbox mode requested but not available."
|
|
367
385
|
)
|
|
368
386
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
input_data.command,
|
|
372
|
-
cwd,
|
|
373
|
-
{cwd},
|
|
374
|
-
)
|
|
375
|
-
if path_validation.behavior == "ask":
|
|
376
|
-
return ValidationResult(result=False, message=path_validation.message)
|
|
387
|
+
# Note: Path validation for sensitive directories (cd/find to /usr, /etc, etc.)
|
|
388
|
+
# is now handled in check_permissions() to allow user confirmation for read-only ops.
|
|
377
389
|
|
|
378
390
|
# Block backgrounding commands we explicitly ignore.
|
|
379
391
|
if input_data.run_in_background:
|
|
@@ -227,9 +227,11 @@ match exactly (including whitespace and indentation)."""
|
|
|
227
227
|
with open(input_data.file_path, "w", encoding="utf-8") as f:
|
|
228
228
|
f.write(new_content)
|
|
229
229
|
|
|
230
|
+
# Use absolute path to ensure consistency with validation lookup
|
|
231
|
+
abs_file_path = os.path.abspath(input_data.file_path)
|
|
230
232
|
try:
|
|
231
233
|
record_snapshot(
|
|
232
|
-
|
|
234
|
+
abs_file_path,
|
|
233
235
|
new_content,
|
|
234
236
|
getattr(context, "file_state_cache", {}),
|
|
235
237
|
)
|
|
@@ -237,7 +239,7 @@ match exactly (including whitespace and indentation)."""
|
|
|
237
239
|
logger.warning(
|
|
238
240
|
"[file_edit_tool] Failed to record file snapshot: %s: %s",
|
|
239
241
|
type(exc).__name__, exc,
|
|
240
|
-
extra={"file_path":
|
|
242
|
+
extra={"file_path": abs_file_path},
|
|
241
243
|
)
|
|
242
244
|
|
|
243
245
|
# Generate diff for display
|
|
@@ -153,9 +153,11 @@ and limit to read only a portion of the file."""
|
|
|
153
153
|
content = "".join(selected_lines)
|
|
154
154
|
|
|
155
155
|
# Remember what we read so we can detect user edits later.
|
|
156
|
+
# Use absolute path to ensure consistency with Edit tool's lookup
|
|
157
|
+
abs_file_path = os.path.abspath(input_data.file_path)
|
|
156
158
|
try:
|
|
157
159
|
record_snapshot(
|
|
158
|
-
|
|
160
|
+
abs_file_path,
|
|
159
161
|
content,
|
|
160
162
|
getattr(context, "file_state_cache", {}),
|
|
161
163
|
offset=offset,
|
|
@@ -160,9 +160,11 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
160
160
|
|
|
161
161
|
bytes_written = len(input_data.content.encode("utf-8"))
|
|
162
162
|
|
|
163
|
+
# Use absolute path to ensure consistency with validation lookup
|
|
164
|
+
abs_file_path = os.path.abspath(input_data.file_path)
|
|
163
165
|
try:
|
|
164
166
|
record_snapshot(
|
|
165
|
-
|
|
167
|
+
abs_file_path,
|
|
166
168
|
input_data.content,
|
|
167
169
|
getattr(context, "file_state_cache", {}),
|
|
168
170
|
)
|
|
@@ -170,7 +172,7 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
170
172
|
logger.warning(
|
|
171
173
|
"[file_write_tool] Failed to record file snapshot: %s: %s",
|
|
172
174
|
type(exc).__name__, exc,
|
|
173
|
-
extra={"file_path":
|
|
175
|
+
extra={"file_path": abs_file_path},
|
|
174
176
|
)
|
|
175
177
|
|
|
176
178
|
output = FileWriteToolOutput(
|
|
@@ -314,9 +314,11 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
314
314
|
)
|
|
315
315
|
|
|
316
316
|
path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
|
|
317
|
+
# Use resolved absolute path to ensure consistency with validation lookup
|
|
318
|
+
abs_notebook_path = str(path.resolve())
|
|
317
319
|
try:
|
|
318
320
|
record_snapshot(
|
|
319
|
-
|
|
321
|
+
abs_notebook_path,
|
|
320
322
|
json.dumps(nb_json, indent=1),
|
|
321
323
|
getattr(context, "file_state_cache", {}),
|
|
322
324
|
)
|
|
@@ -324,7 +326,7 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
324
326
|
logger.warning(
|
|
325
327
|
"[notebook_edit_tool] Failed to record file snapshot: %s: %s",
|
|
326
328
|
type(exc).__name__, exc,
|
|
327
|
-
extra={"file_path":
|
|
329
|
+
extra={"file_path": abs_notebook_path},
|
|
328
330
|
)
|
|
329
331
|
|
|
330
332
|
output = NotebookEditOutput(
|
|
@@ -90,18 +90,20 @@ def _validate_path(raw_path: str, cwd: str, allowed_dirs: Set[str]) -> tuple[boo
|
|
|
90
90
|
return _is_path_allowed(resolved, allowed_dirs), str(resolved)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def
|
|
93
|
+
def _check_command_paths(
|
|
94
94
|
command: str, args: List[str], cwd: str, allowed_dirs: Set[str]
|
|
95
95
|
) -> ValidationResponse:
|
|
96
96
|
if command == "cd":
|
|
97
97
|
target = args[0] if args else os.path.expanduser("~")
|
|
98
98
|
allowed, resolved = _validate_path(target, cwd, allowed_dirs)
|
|
99
99
|
elif command == "ls":
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
# ls is a read-only command, allow it to run on any path
|
|
101
|
+
# This enables viewing system directories like /usr, /etc, etc.
|
|
102
|
+
return ValidationResponse(
|
|
103
|
+
behavior="passthrough",
|
|
104
|
+
message="ls is a read-only command, no path restrictions applied",
|
|
105
|
+
rule_suggestions=None,
|
|
106
|
+
)
|
|
105
107
|
elif command == "find":
|
|
106
108
|
paths: list[str] = []
|
|
107
109
|
for arg in args:
|
|
@@ -132,13 +134,12 @@ def _extract_paths_for_command(
|
|
|
132
134
|
|
|
133
135
|
preview = _format_allowed_dirs_preview(sorted(allowed_dirs))
|
|
134
136
|
action = {
|
|
135
|
-
"cd": "change
|
|
136
|
-
"ls": "list files in",
|
|
137
|
+
"cd": "change directory to",
|
|
137
138
|
"find": "search files in",
|
|
138
139
|
}.get(command, "access")
|
|
139
140
|
return ValidationResponse(
|
|
140
141
|
behavior="ask",
|
|
141
|
-
message=f"{
|
|
142
|
+
message=f"Requesting permission to {action} '{resolved}' (outside allowed directories: {preview})",
|
|
142
143
|
rule_suggestions=None,
|
|
143
144
|
)
|
|
144
145
|
|
|
@@ -167,7 +168,7 @@ def validate_shell_command_paths(
|
|
|
167
168
|
rule_suggestions=None,
|
|
168
169
|
)
|
|
169
170
|
|
|
170
|
-
return
|
|
171
|
+
return _check_command_paths(first, rest, cwd, allowed_dirs)
|
|
171
172
|
|
|
172
173
|
|
|
173
174
|
__all__ = ["ValidationResponse", "validate_shell_command_paths"]
|
|
@@ -218,7 +218,7 @@ def test_path_validation_blocks_outside_allowed(tmp_path: Path):
|
|
|
218
218
|
allowed = {str(tmp_path)}
|
|
219
219
|
result = validate_shell_command_paths("cd /", str(tmp_path), allowed)
|
|
220
220
|
assert result.behavior == "ask"
|
|
221
|
-
assert "
|
|
221
|
+
assert "permission" in result.message.lower() or "outside" in result.message.lower()
|
|
222
222
|
|
|
223
223
|
|
|
224
224
|
def test_path_validation_allows_within_allowed(tmp_path: Path):
|
|
@@ -228,6 +228,23 @@ def test_path_validation_allows_within_allowed(tmp_path: Path):
|
|
|
228
228
|
assert result.behavior == "passthrough"
|
|
229
229
|
|
|
230
230
|
|
|
231
|
+
def test_ls_allows_any_path(tmp_path: Path):
|
|
232
|
+
"""ls is a read-only command and should be allowed on any path."""
|
|
233
|
+
allowed = {str(tmp_path)}
|
|
234
|
+
# ls to /usr should be allowed even though it's outside allowed dirs
|
|
235
|
+
result = validate_shell_command_paths("ls /usr", str(tmp_path), allowed)
|
|
236
|
+
assert result.behavior == "passthrough"
|
|
237
|
+
assert "read-only" in result.message.lower()
|
|
238
|
+
|
|
239
|
+
# ls to /etc should also be allowed
|
|
240
|
+
result = validate_shell_command_paths("ls -la /etc", str(tmp_path), allowed)
|
|
241
|
+
assert result.behavior == "passthrough"
|
|
242
|
+
|
|
243
|
+
# ls to root should also be allowed
|
|
244
|
+
result = validate_shell_command_paths("ls /", str(tmp_path), allowed)
|
|
245
|
+
assert result.behavior == "passthrough"
|
|
246
|
+
|
|
247
|
+
|
|
231
248
|
# =============================================================================
|
|
232
249
|
# Permission Evaluation Tests
|
|
233
250
|
# =============================================================================
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|