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.
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/PKG-INFO +1 -1
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/__init__.py +1 -1
- ripperdoc-0.3.1/ripperdoc/cli/ui/interrupt_listener.py +233 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/message_display.py +7 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/rich_ui.py +83 -73
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/permissions.py +105 -98
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/PKG-INFO +1 -1
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/SOURCES.txt +1 -2
- ripperdoc-0.3.0/ripperdoc/cli/ui/interrupt_handler.py +0 -208
- ripperdoc-0.3.0/tests/test_interrupt_handler.py +0 -505
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/LICENSE +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/README.md +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/pyproject.toml +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/__main__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/cli.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/agents_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/base.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/clear_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/compact_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/config_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/context_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/cost_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/doctor_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/exit_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/help_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/hooks_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/mcp_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/memory_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/models_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/permissions_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/resume_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/skills_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/stats_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/status_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/tasks_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/themes_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/todos_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/commands/tools_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/context_display.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/file_mention_completer.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/helpers.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/panels.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/provider_options.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/spinner.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/thinking_spinner.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/tool_renderers.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/cli/ui/wizard.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/agents.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/commands.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/config.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/custom_commands.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/default_tools.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/config.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/events.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/executor.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/integration.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/llm_callback.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/hooks/manager.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/anthropic.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/base.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/gemini.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/providers/openai.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/query.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/query_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/skills.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/system_prompt.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/theme.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/core/tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/protocol/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/protocol/models.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/protocol/stdio.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/ask_user_question_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/background_shell.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/bash_output_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/bash_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/dynamic_mcp_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/enter_plan_mode_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/exit_plan_mode_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/file_edit_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/file_read_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/file_write_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/glob_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/grep_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/kill_bash_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/ls_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/lsp_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/mcp_tools.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/multi_edit_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/notebook_edit_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/skill_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/task_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/todo_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/tools/tool_search_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/bash_constants.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/bash_output_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/coerce.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/context_length_errors.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/conversation_compaction.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/exit_code_handlers.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/file_watch.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/git_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/image_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/json_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/log.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/lsp.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/mcp.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/memory.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/message_compaction.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/message_formatting.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/messages.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/output_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/path_ignore.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/path_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/pending_messages.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/__init__.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/path_validation_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/shell_command_validation.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/permissions/tool_permission_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/platform.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/prompt.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/safe_get_cwd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/sandbox_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_heatmap.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_history.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_stats.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/session_usage.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/shell_token_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/shell_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/todo.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc/utils/token_estimation.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/dependency_links.txt +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/entry_points.txt +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/requires.txt +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/ripperdoc.egg-info/top_level.txt +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/setup.cfg +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/setup.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_background_notifications.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_background_shell_shutdown.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_background_shell_status.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_cli_commands.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_cli_stdin.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_compact.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_config.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_context_length_errors.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_context_limits.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_custom_commands.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_file_edit_tool.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_file_mention_completer.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_git_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_hooks.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_hooks_cmd.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_mcp_config.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_messages.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_output_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_path_ignore.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_pending_messages.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_permissions.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_platform.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_query_abort.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_rich_ui_suggestions.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_shell_permissions.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_shell_utils.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_skills.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_todo.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_tool_search.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_tools.py +0 -0
- {ripperdoc-0.3.0 → ripperdoc-0.3.1}/tests/test_utils.py +0 -0
|
@@ -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.
|
|
50
|
+
from ripperdoc.cli.ui.interrupt_listener import EscInterruptListener
|
|
51
51
|
from ripperdoc.utils.conversation_compaction import (
|
|
52
52
|
compact_conversation,
|
|
53
53
|
CompactionResult,
|
|
@@ -77,6 +77,8 @@ from ripperdoc.utils.messages import (
|
|
|
77
77
|
UserMessage,
|
|
78
78
|
AssistantMessage,
|
|
79
79
|
ProgressMessage,
|
|
80
|
+
INTERRUPT_MESSAGE,
|
|
81
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
80
82
|
create_user_message,
|
|
81
83
|
)
|
|
82
84
|
from ripperdoc.utils.log import enable_session_file_logging, get_logger
|
|
@@ -349,6 +351,10 @@ class RichUI:
|
|
|
349
351
|
self._exit_reason: Optional[str] = None
|
|
350
352
|
self._using_tty_input = False # Track if we're using /dev/tty for input
|
|
351
353
|
self._thinking_mode_enabled = False # Toggle for extended thinking mode
|
|
354
|
+
self._interrupt_listener = EscInterruptListener(self._schedule_esc_interrupt, logger=logger)
|
|
355
|
+
self._esc_interrupt_seen = False
|
|
356
|
+
self._query_in_progress = False
|
|
357
|
+
self._active_spinner: Optional[ThinkingSpinner] = None
|
|
352
358
|
hook_manager.set_transcript_path(str(self._session_history.path))
|
|
353
359
|
|
|
354
360
|
# Create permission checker with Rich console and PromptSession support
|
|
@@ -398,8 +404,6 @@ class RichUI:
|
|
|
398
404
|
|
|
399
405
|
# Initialize component handlers
|
|
400
406
|
self._message_display = MessageDisplay(self.console, self.verbose, self.show_full_thinking)
|
|
401
|
-
self._interrupt_handler = InterruptHandler()
|
|
402
|
-
self._interrupt_handler.set_abort_callback(self._trigger_abort)
|
|
403
407
|
|
|
404
408
|
# Keep MCP runtime alive for the whole UI session. Create it on the UI loop up front.
|
|
405
409
|
try:
|
|
@@ -440,18 +444,6 @@ class RichUI:
|
|
|
440
444
|
# Properties for backward compatibility with interrupt handler
|
|
441
445
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
442
446
|
|
|
443
|
-
@property
|
|
444
|
-
def _query_interrupted(self) -> bool:
|
|
445
|
-
return self._interrupt_handler.was_interrupted
|
|
446
|
-
|
|
447
|
-
@property
|
|
448
|
-
def _esc_listener_paused(self) -> bool:
|
|
449
|
-
return self._interrupt_handler._esc_listener_paused
|
|
450
|
-
|
|
451
|
-
@_esc_listener_paused.setter
|
|
452
|
-
def _esc_listener_paused(self, value: bool) -> None:
|
|
453
|
-
self._interrupt_handler._esc_listener_paused = value
|
|
454
|
-
|
|
455
447
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
456
448
|
# Thinking mode toggle
|
|
457
449
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -924,6 +916,11 @@ class RichUI:
|
|
|
924
916
|
last_tool_name: Optional[str] = None
|
|
925
917
|
|
|
926
918
|
if isinstance(message.message.content, str):
|
|
919
|
+
if self._esc_interrupt_seen and message.message.content.strip() in (
|
|
920
|
+
INTERRUPT_MESSAGE,
|
|
921
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
922
|
+
):
|
|
923
|
+
return last_tool_name
|
|
927
924
|
with pause():
|
|
928
925
|
self.display_message("Ripperdoc", message.message.content)
|
|
929
926
|
elif isinstance(message.message.content, list):
|
|
@@ -1156,11 +1153,26 @@ class RichUI:
|
|
|
1156
1153
|
spinner = ThinkingSpinner(console, prompt_tokens_est)
|
|
1157
1154
|
|
|
1158
1155
|
def pause_ui() -> None:
|
|
1159
|
-
|
|
1156
|
+
self._pause_interrupt_listener()
|
|
1157
|
+
try:
|
|
1158
|
+
spinner.stop()
|
|
1159
|
+
except (RuntimeError, ValueError, OSError):
|
|
1160
|
+
logger.debug("[ui] Failed to pause spinner")
|
|
1160
1161
|
|
|
1161
1162
|
def resume_ui() -> None:
|
|
1162
|
-
|
|
1163
|
-
|
|
1163
|
+
if self._esc_interrupt_seen:
|
|
1164
|
+
return
|
|
1165
|
+
try:
|
|
1166
|
+
spinner.start()
|
|
1167
|
+
spinner.update("Thinking...")
|
|
1168
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
1169
|
+
logger.debug(
|
|
1170
|
+
"[ui] Failed to restart spinner after pause: %s: %s",
|
|
1171
|
+
type(exc).__name__,
|
|
1172
|
+
exc,
|
|
1173
|
+
)
|
|
1174
|
+
finally:
|
|
1175
|
+
self._resume_interrupt_listener()
|
|
1164
1176
|
|
|
1165
1177
|
self.query_context.pause_ui = pause_ui
|
|
1166
1178
|
self.query_context.resume_ui = resume_ui
|
|
@@ -1169,8 +1181,7 @@ class RichUI:
|
|
|
1169
1181
|
base_permission_checker = self._permission_checker
|
|
1170
1182
|
|
|
1171
1183
|
async def permission_checker(tool: Any, parsed_input: Any) -> bool:
|
|
1172
|
-
|
|
1173
|
-
was_paused = self._pause_interrupt_listener()
|
|
1184
|
+
pause_ui()
|
|
1174
1185
|
try:
|
|
1175
1186
|
if base_permission_checker is not None:
|
|
1176
1187
|
result = await base_permission_checker(tool, parsed_input)
|
|
@@ -1186,18 +1197,7 @@ class RichUI:
|
|
|
1186
1197
|
return allowed
|
|
1187
1198
|
return True
|
|
1188
1199
|
finally:
|
|
1189
|
-
|
|
1190
|
-
# Wrap spinner restart in try-except to prevent exceptions
|
|
1191
|
-
# from discarding the permission result
|
|
1192
|
-
try:
|
|
1193
|
-
spinner.start()
|
|
1194
|
-
spinner.update("Thinking...")
|
|
1195
|
-
except (RuntimeError, ValueError, OSError) as exc:
|
|
1196
|
-
logger.debug(
|
|
1197
|
-
"[ui] Failed to restart spinner after permission check: %s: %s",
|
|
1198
|
-
type(exc).__name__,
|
|
1199
|
-
exc,
|
|
1200
|
-
)
|
|
1200
|
+
resume_ui()
|
|
1201
1201
|
|
|
1202
1202
|
# Process query stream
|
|
1203
1203
|
tool_registry: Dict[str, Dict[str, Any]] = {}
|
|
@@ -1205,6 +1205,10 @@ class RichUI:
|
|
|
1205
1205
|
output_token_est = 0
|
|
1206
1206
|
|
|
1207
1207
|
try:
|
|
1208
|
+
self._active_spinner = spinner
|
|
1209
|
+
self._esc_interrupt_seen = False
|
|
1210
|
+
self._query_in_progress = True
|
|
1211
|
+
self._start_interrupt_listener()
|
|
1208
1212
|
spinner.start()
|
|
1209
1213
|
async for message in query(
|
|
1210
1214
|
messages,
|
|
@@ -1253,6 +1257,9 @@ class RichUI:
|
|
|
1253
1257
|
extra={"session_id": self.session_id},
|
|
1254
1258
|
)
|
|
1255
1259
|
|
|
1260
|
+
self._stop_interrupt_listener()
|
|
1261
|
+
self._query_in_progress = False
|
|
1262
|
+
self._active_spinner = None
|
|
1256
1263
|
self.conversation_messages = messages
|
|
1257
1264
|
logger.info(
|
|
1258
1265
|
"[ui] Query processing completed",
|
|
@@ -1279,21 +1286,49 @@ class RichUI:
|
|
|
1279
1286
|
# ESC Key Interrupt Support
|
|
1280
1287
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1281
1288
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1289
|
+
def _schedule_esc_interrupt(self) -> None:
|
|
1290
|
+
"""Schedule ESC interrupt handling on the UI event loop."""
|
|
1291
|
+
if self._loop.is_closed():
|
|
1292
|
+
return
|
|
1293
|
+
try:
|
|
1294
|
+
self._loop.call_soon_threadsafe(self._handle_esc_interrupt)
|
|
1295
|
+
except RuntimeError:
|
|
1296
|
+
pass
|
|
1297
|
+
|
|
1298
|
+
def _handle_esc_interrupt(self) -> None:
|
|
1299
|
+
"""Abort the current query and display the interrupt notice."""
|
|
1300
|
+
if not self._query_in_progress:
|
|
1301
|
+
return
|
|
1302
|
+
if self._esc_interrupt_seen:
|
|
1303
|
+
return
|
|
1304
|
+
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
1305
|
+
if abort_controller is None or abort_controller.is_set():
|
|
1306
|
+
return
|
|
1307
|
+
|
|
1308
|
+
self._esc_interrupt_seen = True
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
if self.query_context and self.query_context.pause_ui:
|
|
1312
|
+
self.query_context.pause_ui()
|
|
1313
|
+
elif self._active_spinner:
|
|
1314
|
+
self._active_spinner.stop()
|
|
1315
|
+
except (RuntimeError, ValueError, OSError):
|
|
1316
|
+
logger.debug("[ui] Failed to pause spinner for ESC interrupt")
|
|
1317
|
+
|
|
1318
|
+
self._message_display.print_interrupt_notice()
|
|
1319
|
+
abort_controller.set()
|
|
1285
1320
|
|
|
1286
|
-
def
|
|
1287
|
-
self.
|
|
1321
|
+
def _start_interrupt_listener(self) -> None:
|
|
1322
|
+
self._interrupt_listener.start()
|
|
1288
1323
|
|
|
1289
|
-
def
|
|
1290
|
-
|
|
1291
|
-
if self.query_context and hasattr(self.query_context, "abort_controller"):
|
|
1292
|
-
self.query_context.abort_controller.set()
|
|
1324
|
+
def _stop_interrupt_listener(self) -> None:
|
|
1325
|
+
self._interrupt_listener.stop()
|
|
1293
1326
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1327
|
+
def _pause_interrupt_listener(self) -> None:
|
|
1328
|
+
self._interrupt_listener.pause()
|
|
1329
|
+
|
|
1330
|
+
def _resume_interrupt_listener(self) -> None:
|
|
1331
|
+
self._interrupt_listener.resume()
|
|
1297
1332
|
|
|
1298
1333
|
def _run_async(self, coro: Any) -> Any:
|
|
1299
1334
|
"""Run a coroutine on the persistent event loop."""
|
|
@@ -1302,16 +1337,6 @@ class RichUI:
|
|
|
1302
1337
|
asyncio.set_event_loop(self._loop)
|
|
1303
1338
|
return self._loop.run_until_complete(coro)
|
|
1304
1339
|
|
|
1305
|
-
def _run_async_with_esc_interrupt(self, coro: Any) -> bool:
|
|
1306
|
-
"""Run a coroutine with ESC key interrupt support.
|
|
1307
|
-
|
|
1308
|
-
Returns True if interrupted by ESC, False if completed normally.
|
|
1309
|
-
"""
|
|
1310
|
-
if self._loop.is_closed():
|
|
1311
|
-
self._loop = asyncio.new_event_loop()
|
|
1312
|
-
asyncio.set_event_loop(self._loop)
|
|
1313
|
-
return self._loop.run_until_complete(self._run_query_with_esc_interrupt(coro))
|
|
1314
|
-
|
|
1315
1340
|
def run_async(self, coro: Any) -> Any:
|
|
1316
1341
|
"""Public wrapper for running coroutines on the UI event loop."""
|
|
1317
1342
|
return self._run_async(coro)
|
|
@@ -1537,8 +1562,7 @@ class RichUI:
|
|
|
1537
1562
|
console.print()
|
|
1538
1563
|
console.print(
|
|
1539
1564
|
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. "
|
|
1540
|
-
"Press Alt+Enter for newline. Press Tab to toggle thinking mode.
|
|
1541
|
-
"Press ESC to interrupt.[/dim]\n"
|
|
1565
|
+
"Press Alt+Enter for newline. Press Tab to toggle thinking mode.[/dim]\n"
|
|
1542
1566
|
)
|
|
1543
1567
|
|
|
1544
1568
|
session = self.get_prompt_session()
|
|
@@ -1562,8 +1586,7 @@ class RichUI:
|
|
|
1562
1586
|
)
|
|
1563
1587
|
console.print() # Add spacing before response
|
|
1564
1588
|
|
|
1565
|
-
#
|
|
1566
|
-
# since there's no TTY for ESC key detection
|
|
1589
|
+
# Process initial query (ESC interrupt handling removed)
|
|
1567
1590
|
self._run_async(self.process_query(self._initial_query))
|
|
1568
1591
|
|
|
1569
1592
|
logger.info(
|
|
@@ -1614,21 +1637,8 @@ class RichUI:
|
|
|
1614
1637
|
},
|
|
1615
1638
|
)
|
|
1616
1639
|
|
|
1617
|
-
#
|
|
1618
|
-
|
|
1619
|
-
self._run_async(self.process_query(user_input))
|
|
1620
|
-
else:
|
|
1621
|
-
interrupted = self._run_async_with_esc_interrupt(
|
|
1622
|
-
self.process_query(user_input)
|
|
1623
|
-
)
|
|
1624
|
-
if interrupted:
|
|
1625
|
-
console.print(
|
|
1626
|
-
"\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
|
|
1627
|
-
)
|
|
1628
|
-
logger.info(
|
|
1629
|
-
"[ui] Query interrupted by ESC key",
|
|
1630
|
-
extra={"session_id": self.session_id},
|
|
1631
|
-
)
|
|
1640
|
+
# Run query (ESC interrupt handling removed)
|
|
1641
|
+
self._run_async(self.process_query(user_input))
|
|
1632
1642
|
|
|
1633
1643
|
console.print() # Add spacing between interactions
|
|
1634
1644
|
|