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.
Files changed (130) hide show
  1. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/PKG-INFO +1 -1
  2. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/__init__.py +1 -1
  3. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/rich_ui.py +161 -3
  4. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/query.py +12 -1
  5. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/tool.py +5 -3
  6. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/bash_tool.py +20 -8
  7. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/file_edit_tool.py +4 -2
  8. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/file_read_tool.py +3 -1
  9. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/file_write_tool.py +4 -2
  10. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/notebook_edit_tool.py +4 -2
  11. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/path_validation_utils.py +11 -10
  12. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/PKG-INFO +1 -1
  13. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_shell_permissions.py +18 -1
  14. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/LICENSE +0 -0
  15. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/README.md +0 -0
  16. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/pyproject.toml +0 -0
  17. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/__main__.py +0 -0
  18. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/__init__.py +0 -0
  19. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/cli.py +0 -0
  20. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/__init__.py +0 -0
  21. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/agents_cmd.py +0 -0
  22. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/base.py +0 -0
  23. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/clear_cmd.py +0 -0
  24. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/compact_cmd.py +0 -0
  25. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/config_cmd.py +0 -0
  26. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/context_cmd.py +0 -0
  27. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/cost_cmd.py +0 -0
  28. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/doctor_cmd.py +0 -0
  29. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/exit_cmd.py +0 -0
  30. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/help_cmd.py +0 -0
  31. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/mcp_cmd.py +0 -0
  32. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/memory_cmd.py +0 -0
  33. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/models_cmd.py +0 -0
  34. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/permissions_cmd.py +0 -0
  35. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/resume_cmd.py +0 -0
  36. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/status_cmd.py +0 -0
  37. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/tasks_cmd.py +0 -0
  38. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/todos_cmd.py +0 -0
  39. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/commands/tools_cmd.py +0 -0
  40. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/__init__.py +0 -0
  41. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/context_display.py +0 -0
  42. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/helpers.py +0 -0
  43. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/spinner.py +0 -0
  44. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/thinking_spinner.py +0 -0
  45. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/cli/ui/tool_renderers.py +0 -0
  46. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/__init__.py +0 -0
  47. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/agents.py +0 -0
  48. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/commands.py +0 -0
  49. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/config.py +0 -0
  50. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/default_tools.py +0 -0
  51. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/permissions.py +0 -0
  52. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/__init__.py +0 -0
  53. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/anthropic.py +0 -0
  54. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/base.py +0 -0
  55. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/gemini.py +0 -0
  56. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/providers/openai.py +0 -0
  57. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/query_utils.py +0 -0
  58. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/skills.py +0 -0
  59. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/core/system_prompt.py +0 -0
  60. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/sdk/__init__.py +0 -0
  61. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/sdk/client.py +0 -0
  62. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/__init__.py +0 -0
  63. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/ask_user_question_tool.py +0 -0
  64. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/background_shell.py +0 -0
  65. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/bash_output_tool.py +0 -0
  66. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/dynamic_mcp_tool.py +0 -0
  67. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/enter_plan_mode_tool.py +0 -0
  68. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/exit_plan_mode_tool.py +0 -0
  69. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/glob_tool.py +0 -0
  70. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/grep_tool.py +0 -0
  71. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/kill_bash_tool.py +0 -0
  72. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/ls_tool.py +0 -0
  73. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/mcp_tools.py +0 -0
  74. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/multi_edit_tool.py +0 -0
  75. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/skill_tool.py +0 -0
  76. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/task_tool.py +0 -0
  77. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/todo_tool.py +0 -0
  78. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/tools/tool_search_tool.py +0 -0
  79. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/__init__.py +0 -0
  80. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/bash_constants.py +0 -0
  81. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/bash_output_utils.py +0 -0
  82. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/coerce.py +0 -0
  83. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/context_length_errors.py +0 -0
  84. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/exit_code_handlers.py +0 -0
  85. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/file_watch.py +0 -0
  86. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/git_utils.py +0 -0
  87. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/json_utils.py +0 -0
  88. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/log.py +0 -0
  89. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/mcp.py +0 -0
  90. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/memory.py +0 -0
  91. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/message_compaction.py +0 -0
  92. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/messages.py +0 -0
  93. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/output_utils.py +0 -0
  94. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/path_ignore.py +0 -0
  95. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/path_utils.py +0 -0
  96. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/__init__.py +0 -0
  97. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/shell_command_validation.py +0 -0
  98. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/permissions/tool_permission_utils.py +0 -0
  99. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/prompt.py +0 -0
  100. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/safe_get_cwd.py +0 -0
  101. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/sandbox_utils.py +0 -0
  102. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/session_history.py +0 -0
  103. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/session_usage.py +0 -0
  104. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/shell_token_utils.py +0 -0
  105. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/shell_utils.py +0 -0
  106. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/todo.py +0 -0
  107. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc/utils/token_estimation.py +0 -0
  108. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/SOURCES.txt +0 -0
  109. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/dependency_links.txt +0 -0
  110. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/entry_points.txt +0 -0
  111. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/requires.txt +0 -0
  112. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/ripperdoc.egg-info/top_level.txt +0 -0
  113. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/setup.cfg +0 -0
  114. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/setup.py +0 -0
  115. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_background_shell_shutdown.py +0 -0
  116. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_cli_commands.py +0 -0
  117. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_config.py +0 -0
  118. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_context_length_errors.py +0 -0
  119. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_context_limits.py +0 -0
  120. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_mcp_config.py +0 -0
  121. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_messages.py +0 -0
  122. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_output_utils.py +0 -0
  123. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_path_ignore.py +0 -0
  124. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_permissions.py +0 -0
  125. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_query_abort.py +0 -0
  126. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_sdk.py +0 -0
  127. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_skills.py +0 -0
  128. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_todo.py +0 -0
  129. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_tool_search.py +0 -0
  130. {ripperdoc-0.2.5 → ripperdoc-0.2.6}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ripperdoc
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: AI-powered terminal assistant for coding tasks
5
5
  Author: Ripperdoc Team
6
6
  License: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """Ripperdoc - AI-powered coding agent."""
2
2
 
3
- __version__ = "0.2.5"
3
+ __version__ = "0.2.6"
@@ -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._run_async(self.process_query(user_input))
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}, return_when=asyncio.FIRST_COMPLETED
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
- file_state_cache: Dict[str, "FileSnapshot"] = Field(default_factory=dict)
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
- cwd = safe_get_cwd()
370
- path_validation = validate_shell_command_paths(
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
- input_data.file_path,
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": input_data.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
- input_data.file_path,
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
- input_data.file_path,
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": input_data.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
- input_data.notebook_path,
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": input_data.notebook_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 _extract_paths_for_command(
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
- candidates = [arg for arg in args if not arg.startswith("-")] or ["."]
101
- for candidate in candidates:
102
- allowed, resolved = _validate_path(candidate, cwd, allowed_dirs)
103
- if not allowed:
104
- break
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 directories to",
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"{command} in '{resolved}' was blocked. For security, this session may only {action} the allowed working directories: {preview}.",
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 _extract_paths_for_command(first, rest, cwd, allowed_dirs)
171
+ return _check_command_paths(first, rest, cwd, allowed_dirs)
171
172
 
172
173
 
173
174
  __all__ = ["ValidationResponse", "validate_shell_command_paths"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ripperdoc
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: AI-powered terminal assistant for coding tasks
5
5
  Author: Ripperdoc Team
6
6
  License: Apache-2.0
@@ -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 "blocked" in result.message.lower()
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