ripperdoc 0.3.0__tar.gz → 0.3.1__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 (175) hide show
  1. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/PKG-INFO +1 -1
  2. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/__init__.py +1 -1
  3. ripperdoc-0.3.1/ripperdoc/cli/ui/interrupt_listener.py +233 -0
  4. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/message_display.py +7 -0
  5. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/rich_ui.py +83 -73
  6. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/permissions.py +105 -98
  7. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/PKG-INFO +1 -1
  8. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/SOURCES.txt +1 -2
  9. ripperdoc-0.3.0/ripperdoc/cli/ui/interrupt_handler.py +0 -208
  10. ripperdoc-0.3.0/tests/test_interrupt_handler.py +0 -505
  11. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/LICENSE +0 -0
  12. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/README.md +0 -0
  13. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/pyproject.toml +0 -0
  14. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/__main__.py +0 -0
  15. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/__init__.py +0 -0
  16. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/cli.py +0 -0
  17. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/__init__.py +0 -0
  18. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/agents_cmd.py +0 -0
  19. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/base.py +0 -0
  20. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/clear_cmd.py +0 -0
  21. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/compact_cmd.py +0 -0
  22. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/config_cmd.py +0 -0
  23. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/context_cmd.py +0 -0
  24. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/cost_cmd.py +0 -0
  25. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/doctor_cmd.py +0 -0
  26. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/exit_cmd.py +0 -0
  27. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/help_cmd.py +0 -0
  28. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/hooks_cmd.py +0 -0
  29. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/mcp_cmd.py +0 -0
  30. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/memory_cmd.py +0 -0
  31. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/models_cmd.py +0 -0
  32. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/permissions_cmd.py +0 -0
  33. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/resume_cmd.py +0 -0
  34. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/skills_cmd.py +0 -0
  35. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/stats_cmd.py +0 -0
  36. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/status_cmd.py +0 -0
  37. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/tasks_cmd.py +0 -0
  38. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/themes_cmd.py +0 -0
  39. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/todos_cmd.py +0 -0
  40. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/tools_cmd.py +0 -0
  41. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/__init__.py +0 -0
  42. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/context_display.py +0 -0
  43. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/file_mention_completer.py +0 -0
  44. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/helpers.py +0 -0
  45. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/panels.py +0 -0
  46. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/provider_options.py +0 -0
  47. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/spinner.py +0 -0
  48. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/thinking_spinner.py +0 -0
  49. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/tool_renderers.py +0 -0
  50. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/wizard.py +0 -0
  51. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/__init__.py +0 -0
  52. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/agents.py +0 -0
  53. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/commands.py +0 -0
  54. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/config.py +0 -0
  55. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/custom_commands.py +0 -0
  56. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/default_tools.py +0 -0
  57. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/__init__.py +0 -0
  58. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/config.py +0 -0
  59. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/events.py +0 -0
  60. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/executor.py +0 -0
  61. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/integration.py +0 -0
  62. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/llm_callback.py +0 -0
  63. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/manager.py +0 -0
  64. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/__init__.py +0 -0
  65. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/anthropic.py +0 -0
  66. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/base.py +0 -0
  67. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/gemini.py +0 -0
  68. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/openai.py +0 -0
  69. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/query.py +0 -0
  70. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/query_utils.py +0 -0
  71. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/skills.py +0 -0
  72. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/system_prompt.py +0 -0
  73. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/theme.py +0 -0
  74. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/tool.py +0 -0
  75. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/protocol/__init__.py +0 -0
  76. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/protocol/models.py +0 -0
  77. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/protocol/stdio.py +0 -0
  78. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/__init__.py +0 -0
  79. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/ask_user_question_tool.py +0 -0
  80. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/background_shell.py +0 -0
  81. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/bash_output_tool.py +0 -0
  82. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/bash_tool.py +0 -0
  83. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/dynamic_mcp_tool.py +0 -0
  84. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/enter_plan_mode_tool.py +0 -0
  85. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/exit_plan_mode_tool.py +0 -0
  86. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/file_edit_tool.py +0 -0
  87. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/file_read_tool.py +0 -0
  88. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/file_write_tool.py +0 -0
  89. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/glob_tool.py +0 -0
  90. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/grep_tool.py +0 -0
  91. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/kill_bash_tool.py +0 -0
  92. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/ls_tool.py +0 -0
  93. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/lsp_tool.py +0 -0
  94. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/mcp_tools.py +0 -0
  95. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/multi_edit_tool.py +0 -0
  96. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/notebook_edit_tool.py +0 -0
  97. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/skill_tool.py +0 -0
  98. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/task_tool.py +0 -0
  99. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/todo_tool.py +0 -0
  100. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/tool_search_tool.py +0 -0
  101. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/__init__.py +0 -0
  102. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/bash_constants.py +0 -0
  103. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/bash_output_utils.py +0 -0
  104. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/coerce.py +0 -0
  105. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/context_length_errors.py +0 -0
  106. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/conversation_compaction.py +0 -0
  107. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/exit_code_handlers.py +0 -0
  108. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/file_watch.py +0 -0
  109. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/git_utils.py +0 -0
  110. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/image_utils.py +0 -0
  111. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/json_utils.py +0 -0
  112. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/log.py +0 -0
  113. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/lsp.py +0 -0
  114. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/mcp.py +0 -0
  115. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/memory.py +0 -0
  116. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/message_compaction.py +0 -0
  117. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/message_formatting.py +0 -0
  118. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/messages.py +0 -0
  119. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/output_utils.py +0 -0
  120. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/path_ignore.py +0 -0
  121. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/path_utils.py +0 -0
  122. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/pending_messages.py +0 -0
  123. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/__init__.py +0 -0
  124. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/path_validation_utils.py +0 -0
  125. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/shell_command_validation.py +0 -0
  126. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/tool_permission_utils.py +0 -0
  127. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/platform.py +0 -0
  128. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/prompt.py +0 -0
  129. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/safe_get_cwd.py +0 -0
  130. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/sandbox_utils.py +0 -0
  131. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_heatmap.py +0 -0
  132. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_history.py +0 -0
  133. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_stats.py +0 -0
  134. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_usage.py +0 -0
  135. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/shell_token_utils.py +0 -0
  136. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/shell_utils.py +0 -0
  137. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/todo.py +0 -0
  138. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/token_estimation.py +0 -0
  139. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/dependency_links.txt +0 -0
  140. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/entry_points.txt +0 -0
  141. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/requires.txt +0 -0
  142. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/top_level.txt +0 -0
  143. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/setup.cfg +0 -0
  144. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/setup.py +0 -0
  145. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_background_notifications.py +0 -0
  146. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_background_shell_shutdown.py +0 -0
  147. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_background_shell_status.py +0 -0
  148. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_cli_commands.py +0 -0
  149. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_cli_stdin.py +0 -0
  150. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_compact.py +0 -0
  151. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_config.py +0 -0
  152. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_context_length_errors.py +0 -0
  153. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_context_limits.py +0 -0
  154. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_custom_commands.py +0 -0
  155. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_file_edit_tool.py +0 -0
  156. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_file_mention_completer.py +0 -0
  157. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_git_utils.py +0 -0
  158. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_hooks.py +0 -0
  159. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_hooks_cmd.py +0 -0
  160. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_mcp_config.py +0 -0
  161. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_messages.py +0 -0
  162. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_output_utils.py +0 -0
  163. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_path_ignore.py +0 -0
  164. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_pending_messages.py +0 -0
  165. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_permissions.py +0 -0
  166. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_platform.py +0 -0
  167. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_query_abort.py +0 -0
  168. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_rich_ui_suggestions.py +0 -0
  169. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_shell_permissions.py +0 -0
  170. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_shell_utils.py +0 -0
  171. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_skills.py +0 -0
  172. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_todo.py +0 -0
  173. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_tool_search.py +0 -0
  174. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_tools.py +0 -0
  175. {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_utils.py +0 -0
@@ -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,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