ripperdoc 0.2.6__py3-none-any.whl → 0.2.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/commands/clear_cmd.py +1 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/resume_cmd.py +71 -37
- ripperdoc/cli/ui/file_mention_completer.py +221 -0
- ripperdoc/cli/ui/helpers.py +100 -3
- ripperdoc/cli/ui/interrupt_handler.py +175 -0
- ripperdoc/cli/ui/message_display.py +249 -0
- ripperdoc/cli/ui/panels.py +60 -0
- ripperdoc/cli/ui/rich_ui.py +147 -630
- ripperdoc/cli/ui/tool_renderers.py +2 -2
- ripperdoc/core/agents.py +4 -4
- ripperdoc/core/query_utils.py +1 -1
- ripperdoc/core/tool.py +1 -1
- ripperdoc/tools/bash_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +2 -2
- ripperdoc/tools/file_read_tool.py +1 -1
- ripperdoc/tools/multi_edit_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +476 -0
- ripperdoc/utils/message_compaction.py +109 -154
- ripperdoc/utils/message_formatting.py +216 -0
- ripperdoc/utils/messages.py +31 -9
- ripperdoc/utils/session_history.py +19 -7
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/METADATA +1 -1
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/RECORD +29 -23
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.7.dist-info}/top_level.txt +0 -0
ripperdoc/__init__.py
CHANGED
|
@@ -12,6 +12,9 @@ from ripperdoc.utils.session_history import (
|
|
|
12
12
|
|
|
13
13
|
from .base import SlashCommand
|
|
14
14
|
|
|
15
|
+
# Number of sessions to display per page
|
|
16
|
+
PAGE_SIZE = 20
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
def _format_time(dt: datetime) -> str:
|
|
17
20
|
return dt.strftime("%Y-%m-%d %H:%M")
|
|
@@ -23,48 +26,79 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
|
|
|
23
26
|
ui.console.print("[yellow]No saved sessions found for this project.[/yellow]")
|
|
24
27
|
return None
|
|
25
28
|
|
|
26
|
-
# If
|
|
29
|
+
# If arg is provided, treat it as session id prefix match
|
|
27
30
|
if arg.strip():
|
|
28
|
-
if arg.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
match = next((s for s in sessions if s.session_id.startswith(arg.strip())), None)
|
|
32
|
+
if match:
|
|
33
|
+
return match
|
|
34
|
+
ui.console.print(f"[red]No session found matching '{escape(arg)}'.[/red]")
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
# Pagination settings
|
|
38
|
+
current_page = 0
|
|
39
|
+
total_pages = (len(sessions) + PAGE_SIZE - 1) // PAGE_SIZE
|
|
40
|
+
|
|
41
|
+
while True:
|
|
42
|
+
start_idx = current_page * PAGE_SIZE
|
|
43
|
+
end_idx = min(start_idx + PAGE_SIZE, len(sessions))
|
|
44
|
+
page_sessions = sessions[start_idx:end_idx]
|
|
45
|
+
|
|
46
|
+
ui.console.print(f"\n[bold]Saved sessions (Page {current_page + 1}/{total_pages}):[/bold]")
|
|
47
|
+
for idx, summary in enumerate(page_sessions, start=start_idx):
|
|
32
48
|
ui.console.print(
|
|
33
|
-
f"[
|
|
34
|
-
f"
|
|
49
|
+
f" [{idx}] {summary.session_id} "
|
|
50
|
+
f"({summary.message_count} messages, "
|
|
51
|
+
f"{_format_time(summary.created_at)} → {_format_time(summary.updated_at)}) "
|
|
52
|
+
f"{escape(summary.last_prompt)}",
|
|
53
|
+
markup=False,
|
|
35
54
|
)
|
|
36
|
-
else:
|
|
37
|
-
# Treat arg as session id if it matches.
|
|
38
|
-
match = next((s for s in sessions if s.session_id.startswith(arg.strip())), None)
|
|
39
|
-
if match:
|
|
40
|
-
return match
|
|
41
|
-
ui.console.print(f"[red]No session found matching '{escape(arg)}'.[/red]")
|
|
42
|
-
return None
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
markup=False,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
choice_text = ui.console.input("\nSelect a session index (Enter to cancel): ").strip()
|
|
55
|
-
if not choice_text:
|
|
56
|
-
return None
|
|
57
|
-
if not choice_text.isdigit():
|
|
58
|
-
ui.console.print("[red]Please enter a number.[/red]")
|
|
59
|
-
return None
|
|
56
|
+
# Show navigation hints
|
|
57
|
+
nav_hints = []
|
|
58
|
+
if current_page > 0:
|
|
59
|
+
nav_hints.append("'p' for previous page")
|
|
60
|
+
if current_page < total_pages - 1:
|
|
61
|
+
nav_hints.append("'n' for next page")
|
|
62
|
+
nav_hints.append("Enter to cancel")
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
prompt = f"\nSelect session index"
|
|
65
|
+
if nav_hints:
|
|
66
|
+
prompt += f" ({', '.join(nav_hints)})"
|
|
67
|
+
prompt += ": "
|
|
68
|
+
|
|
69
|
+
choice_text = ui.console.input(prompt).strip().lower()
|
|
70
|
+
|
|
71
|
+
if not choice_text:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Handle pagination commands
|
|
75
|
+
if choice_text == 'n':
|
|
76
|
+
if current_page < total_pages - 1:
|
|
77
|
+
current_page += 1
|
|
78
|
+
continue
|
|
79
|
+
else:
|
|
80
|
+
ui.console.print("[yellow]Already at the last page.[/yellow]")
|
|
81
|
+
continue
|
|
82
|
+
elif choice_text == 'p':
|
|
83
|
+
if current_page > 0:
|
|
84
|
+
current_page -= 1
|
|
85
|
+
continue
|
|
86
|
+
else:
|
|
87
|
+
ui.console.print("[yellow]Already at the first page.[/yellow]")
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Handle session selection
|
|
91
|
+
if not choice_text.isdigit():
|
|
92
|
+
ui.console.print("[red]Please enter a session index number, 'n' for next page, or 'p' for previous page.[/red]")
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
idx = int(choice_text)
|
|
96
|
+
if idx < 0 or idx >= len(sessions):
|
|
97
|
+
ui.console.print(
|
|
98
|
+
f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
|
|
99
|
+
)
|
|
100
|
+
continue
|
|
101
|
+
return sessions[idx]
|
|
68
102
|
|
|
69
103
|
|
|
70
104
|
def _handle(ui: Any, arg: str) -> bool:
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""File mention completer for @ symbol completion.
|
|
2
|
+
|
|
3
|
+
This module provides file path completion when users type @ followed by a filename.
|
|
4
|
+
Supports recursive search across the entire project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Iterable, List
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
11
|
+
|
|
12
|
+
from ripperdoc.utils.path_ignore import should_skip_path, IgnoreFilter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileMentionCompleter(Completer):
|
|
16
|
+
"""Autocomplete for file paths when typing @.
|
|
17
|
+
|
|
18
|
+
Supports recursive search - typing 'cli' will match 'ripperdoc/cli/cli.py'
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, project_path: Path, ignore_filter: IgnoreFilter):
|
|
22
|
+
"""Initialize the file mention completer.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
project_path: Root path of the project
|
|
26
|
+
ignore_filter: Pre-built ignore filter for filtering files
|
|
27
|
+
"""
|
|
28
|
+
self.project_path = project_path
|
|
29
|
+
self.ignore_filter = ignore_filter
|
|
30
|
+
|
|
31
|
+
def _collect_files_recursive(self, root_dir: Path, max_depth: int = 5) -> List[Path]:
|
|
32
|
+
"""Recursively collect all files from root_dir, respecting ignore rules.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
root_dir: Directory to search from
|
|
36
|
+
max_depth: Maximum directory depth to search
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
List of file paths relative to project root
|
|
40
|
+
"""
|
|
41
|
+
files = []
|
|
42
|
+
|
|
43
|
+
def _walk(current_dir: Path, depth: int):
|
|
44
|
+
if depth > max_depth:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
for item in current_dir.iterdir():
|
|
49
|
+
# Use the project's ignore filter to skip files
|
|
50
|
+
if should_skip_path(
|
|
51
|
+
item,
|
|
52
|
+
self.project_path,
|
|
53
|
+
ignore_filter=self.ignore_filter,
|
|
54
|
+
skip_hidden=True,
|
|
55
|
+
):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if item.is_file():
|
|
59
|
+
files.append(item)
|
|
60
|
+
elif item.is_dir():
|
|
61
|
+
# Recurse into subdirectory
|
|
62
|
+
_walk(item, depth + 1)
|
|
63
|
+
except (OSError, PermissionError):
|
|
64
|
+
# Skip directories we can't read
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
_walk(root_dir, 0)
|
|
68
|
+
return files
|
|
69
|
+
|
|
70
|
+
def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
|
|
71
|
+
"""Get completion suggestions for the current input.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
document: The current document/input
|
|
75
|
+
complete_event: Completion event
|
|
76
|
+
|
|
77
|
+
Yields:
|
|
78
|
+
Completion objects with file paths
|
|
79
|
+
"""
|
|
80
|
+
text = document.text_before_cursor
|
|
81
|
+
|
|
82
|
+
# Find the last @ symbol in the text
|
|
83
|
+
at_pos = text.rfind("@")
|
|
84
|
+
if at_pos == -1:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Extract the query after the @ symbol
|
|
88
|
+
query = text[at_pos + 1:].strip()
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
matches = []
|
|
92
|
+
|
|
93
|
+
# If query contains path separator, do directory-based search
|
|
94
|
+
if "/" in query or "\\" in query:
|
|
95
|
+
# User is typing a specific path
|
|
96
|
+
query_path = Path(query.replace("\\", "/"))
|
|
97
|
+
|
|
98
|
+
if query.endswith(("/", "\\")):
|
|
99
|
+
# Show contents of this directory
|
|
100
|
+
search_dir = self.project_path / query_path
|
|
101
|
+
if search_dir.exists() and search_dir.is_dir():
|
|
102
|
+
for item in sorted(search_dir.iterdir()):
|
|
103
|
+
if should_skip_path(
|
|
104
|
+
item,
|
|
105
|
+
self.project_path,
|
|
106
|
+
ignore_filter=self.ignore_filter,
|
|
107
|
+
skip_hidden=True,
|
|
108
|
+
):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
rel_path = item.relative_to(self.project_path)
|
|
113
|
+
display_path = str(rel_path)
|
|
114
|
+
if item.is_dir():
|
|
115
|
+
display_path += "/"
|
|
116
|
+
|
|
117
|
+
# Right side: show type only
|
|
118
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
119
|
+
|
|
120
|
+
matches.append((display_path, item, meta, 0))
|
|
121
|
+
except ValueError:
|
|
122
|
+
continue
|
|
123
|
+
else:
|
|
124
|
+
# Match files in the parent directory
|
|
125
|
+
parent_dir = self.project_path / query_path.parent
|
|
126
|
+
pattern = f"{query_path.name}*"
|
|
127
|
+
|
|
128
|
+
if parent_dir.exists() and parent_dir.is_dir():
|
|
129
|
+
for item in sorted(parent_dir.iterdir()):
|
|
130
|
+
if should_skip_path(
|
|
131
|
+
item,
|
|
132
|
+
self.project_path,
|
|
133
|
+
ignore_filter=self.ignore_filter,
|
|
134
|
+
skip_hidden=True,
|
|
135
|
+
):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
import fnmatch
|
|
139
|
+
if fnmatch.fnmatch(item.name.lower(), pattern.lower()):
|
|
140
|
+
try:
|
|
141
|
+
rel_path = item.relative_to(self.project_path)
|
|
142
|
+
display_path = str(rel_path)
|
|
143
|
+
if item.is_dir():
|
|
144
|
+
display_path += "/"
|
|
145
|
+
|
|
146
|
+
# Right side: show type only
|
|
147
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
148
|
+
|
|
149
|
+
matches.append((display_path, item, meta, 0))
|
|
150
|
+
except ValueError:
|
|
151
|
+
continue
|
|
152
|
+
else:
|
|
153
|
+
# Recursive search: match query against filename anywhere in project
|
|
154
|
+
if not query:
|
|
155
|
+
# No query: show top-level items only
|
|
156
|
+
for item in sorted(self.project_path.iterdir()):
|
|
157
|
+
if should_skip_path(
|
|
158
|
+
item,
|
|
159
|
+
self.project_path,
|
|
160
|
+
ignore_filter=self.ignore_filter,
|
|
161
|
+
skip_hidden=True,
|
|
162
|
+
):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
rel_path = item.relative_to(self.project_path)
|
|
167
|
+
display_path = str(rel_path)
|
|
168
|
+
if item.is_dir():
|
|
169
|
+
display_path += "/"
|
|
170
|
+
|
|
171
|
+
# Right side: show type only
|
|
172
|
+
meta = "📁 directory" if item.is_dir() else "📄 file"
|
|
173
|
+
matches.append((display_path, item, meta, 0))
|
|
174
|
+
except ValueError:
|
|
175
|
+
continue
|
|
176
|
+
else:
|
|
177
|
+
# Recursively search for files matching the query
|
|
178
|
+
all_files = self._collect_files_recursive(self.project_path)
|
|
179
|
+
query_lower = query.lower()
|
|
180
|
+
|
|
181
|
+
for file_path in all_files:
|
|
182
|
+
try:
|
|
183
|
+
rel_path = file_path.relative_to(self.project_path)
|
|
184
|
+
file_name = file_path.name
|
|
185
|
+
|
|
186
|
+
# Match against filename
|
|
187
|
+
if query_lower in file_name.lower():
|
|
188
|
+
# Calculate relevance score (prefer exact matches and shorter names)
|
|
189
|
+
score = 0
|
|
190
|
+
if file_name.lower().startswith(query_lower):
|
|
191
|
+
score += 100 # Prefix match is highly relevant
|
|
192
|
+
if file_name.lower() == query_lower:
|
|
193
|
+
score += 200 # Exact match is most relevant
|
|
194
|
+
score -= len(str(rel_path)) # Prefer shorter paths
|
|
195
|
+
|
|
196
|
+
display_path = str(rel_path)
|
|
197
|
+
|
|
198
|
+
# Right side: show type only
|
|
199
|
+
meta = "📄 file"
|
|
200
|
+
|
|
201
|
+
matches.append((display_path, file_path, meta, score))
|
|
202
|
+
except ValueError:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Sort matches by score (descending) and then by path
|
|
206
|
+
matches.sort(key=lambda x: (-x[3], x[0]))
|
|
207
|
+
|
|
208
|
+
# Limit results to prevent overwhelming the user
|
|
209
|
+
matches = matches[:50]
|
|
210
|
+
|
|
211
|
+
for display_path, item, meta, score in matches:
|
|
212
|
+
yield Completion(
|
|
213
|
+
display_path,
|
|
214
|
+
start_position=-(len(query) + 1), # +1 to include the @ symbol
|
|
215
|
+
display=display_path,
|
|
216
|
+
display_meta=meta,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
except (OSError, ValueError, RuntimeError):
|
|
220
|
+
# Silently ignore errors during completion
|
|
221
|
+
pass
|
ripperdoc/cli/ui/helpers.py
CHANGED
|
@@ -1,10 +1,107 @@
|
|
|
1
|
-
"""Shared helper functions for the Rich UI."""
|
|
1
|
+
"""Shared helper functions and constants for the Rich UI."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import random
|
|
4
|
+
from typing import List, Optional
|
|
4
5
|
|
|
5
6
|
from ripperdoc.core.config import get_current_model_profile, get_global_config, ModelProfile
|
|
6
7
|
|
|
7
8
|
|
|
9
|
+
# Fun words to display while the AI is "thinking"
|
|
10
|
+
THINKING_WORDS: List[str] = [
|
|
11
|
+
"Accomplishing",
|
|
12
|
+
"Actioning",
|
|
13
|
+
"Actualizing",
|
|
14
|
+
"Baking",
|
|
15
|
+
"Booping",
|
|
16
|
+
"Brewing",
|
|
17
|
+
"Calculating",
|
|
18
|
+
"Cerebrating",
|
|
19
|
+
"Channelling",
|
|
20
|
+
"Churning",
|
|
21
|
+
"Coalescing",
|
|
22
|
+
"Cogitating",
|
|
23
|
+
"Computing",
|
|
24
|
+
"Combobulating",
|
|
25
|
+
"Concocting",
|
|
26
|
+
"Conjuring",
|
|
27
|
+
"Considering",
|
|
28
|
+
"Contemplating",
|
|
29
|
+
"Cooking",
|
|
30
|
+
"Crafting",
|
|
31
|
+
"Creating",
|
|
32
|
+
"Crunching",
|
|
33
|
+
"Deciphering",
|
|
34
|
+
"Deliberating",
|
|
35
|
+
"Determining",
|
|
36
|
+
"Discombobulating",
|
|
37
|
+
"Divining",
|
|
38
|
+
"Doing",
|
|
39
|
+
"Effecting",
|
|
40
|
+
"Elucidating",
|
|
41
|
+
"Enchanting",
|
|
42
|
+
"Envisioning",
|
|
43
|
+
"Finagling",
|
|
44
|
+
"Flibbertigibbeting",
|
|
45
|
+
"Forging",
|
|
46
|
+
"Forming",
|
|
47
|
+
"Frolicking",
|
|
48
|
+
"Generating",
|
|
49
|
+
"Germinating",
|
|
50
|
+
"Hatching",
|
|
51
|
+
"Herding",
|
|
52
|
+
"Honking",
|
|
53
|
+
"Ideating",
|
|
54
|
+
"Imagining",
|
|
55
|
+
"Incubating",
|
|
56
|
+
"Inferring",
|
|
57
|
+
"Manifesting",
|
|
58
|
+
"Marinating",
|
|
59
|
+
"Meandering",
|
|
60
|
+
"Moseying",
|
|
61
|
+
"Mulling",
|
|
62
|
+
"Mustering",
|
|
63
|
+
"Musing",
|
|
64
|
+
"Noodling",
|
|
65
|
+
"Percolating",
|
|
66
|
+
"Perusing",
|
|
67
|
+
"Philosophising",
|
|
68
|
+
"Pontificating",
|
|
69
|
+
"Pondering",
|
|
70
|
+
"Processing",
|
|
71
|
+
"Puttering",
|
|
72
|
+
"Puzzling",
|
|
73
|
+
"Reticulating",
|
|
74
|
+
"Ruminating",
|
|
75
|
+
"Scheming",
|
|
76
|
+
"Schlepping",
|
|
77
|
+
"Shimmying",
|
|
78
|
+
"Simmering",
|
|
79
|
+
"Smooshing",
|
|
80
|
+
"Spelunking",
|
|
81
|
+
"Spinning",
|
|
82
|
+
"Stewing",
|
|
83
|
+
"Sussing",
|
|
84
|
+
"Synthesizing",
|
|
85
|
+
"Thinking",
|
|
86
|
+
"Tinkering",
|
|
87
|
+
"Transmuting",
|
|
88
|
+
"Unfurling",
|
|
89
|
+
"Unravelling",
|
|
90
|
+
"Vibing",
|
|
91
|
+
"Wandering",
|
|
92
|
+
"Whirring",
|
|
93
|
+
"Wibbling",
|
|
94
|
+
"Wizarding",
|
|
95
|
+
"Working",
|
|
96
|
+
"Wrangling",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_random_thinking_word() -> str:
|
|
101
|
+
"""Return a random thinking word for spinner display."""
|
|
102
|
+
return random.choice(THINKING_WORDS)
|
|
103
|
+
|
|
104
|
+
|
|
8
105
|
def get_profile_for_pointer(pointer: str = "main") -> Optional[ModelProfile]:
|
|
9
106
|
"""Return the configured ModelProfile for a logical pointer or default."""
|
|
10
107
|
profile = get_current_model_profile(pointer)
|
|
@@ -19,4 +116,4 @@ def get_profile_for_pointer(pointer: str = "main") -> Optional[ModelProfile]:
|
|
|
19
116
|
return None
|
|
20
117
|
|
|
21
118
|
|
|
22
|
-
__all__ = ["get_profile_for_pointer"]
|
|
119
|
+
__all__ = ["get_profile_for_pointer", "THINKING_WORDS", "get_random_thinking_word"]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Interrupt handling for RichUI.
|
|
2
|
+
|
|
3
|
+
This module handles ESC/Ctrl+C key detection during query execution,
|
|
4
|
+
including terminal raw mode management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import contextlib
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Optional, Set
|
|
11
|
+
|
|
12
|
+
from ripperdoc.utils.log import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger()
|
|
15
|
+
|
|
16
|
+
# Keys that trigger interrupt
|
|
17
|
+
INTERRUPT_KEYS: Set[str] = {'\x1b', '\x03'} # ESC, Ctrl+C
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InterruptHandler:
|
|
21
|
+
"""Handles keyboard interrupt detection during async operations."""
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
"""Initialize the interrupt handler."""
|
|
25
|
+
self._query_interrupted: bool = False
|
|
26
|
+
self._esc_listener_active: bool = False
|
|
27
|
+
self._esc_listener_paused: bool = False
|
|
28
|
+
self._stdin_fd: Optional[int] = None
|
|
29
|
+
self._stdin_old_settings: Optional[list] = None
|
|
30
|
+
self._stdin_in_raw_mode: bool = False
|
|
31
|
+
self._abort_callback: Optional[Any] = None
|
|
32
|
+
|
|
33
|
+
def set_abort_callback(self, callback: Any) -> None:
|
|
34
|
+
"""Set the callback to trigger when interrupt is detected."""
|
|
35
|
+
self._abort_callback = callback
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def was_interrupted(self) -> bool:
|
|
39
|
+
"""Check if the last query was interrupted."""
|
|
40
|
+
return self._query_interrupted
|
|
41
|
+
|
|
42
|
+
def pause_listener(self) -> bool:
|
|
43
|
+
"""Pause ESC listener and restore cooked terminal mode if we own raw mode.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Previous paused state for later restoration.
|
|
47
|
+
"""
|
|
48
|
+
prev = self._esc_listener_paused
|
|
49
|
+
self._esc_listener_paused = True
|
|
50
|
+
try:
|
|
51
|
+
import termios
|
|
52
|
+
except ImportError:
|
|
53
|
+
return prev
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
self._stdin_fd is not None
|
|
57
|
+
and self._stdin_old_settings is not None
|
|
58
|
+
and self._stdin_in_raw_mode
|
|
59
|
+
):
|
|
60
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
61
|
+
termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._stdin_old_settings)
|
|
62
|
+
self._stdin_in_raw_mode = False
|
|
63
|
+
return prev
|
|
64
|
+
|
|
65
|
+
def resume_listener(self, previous_state: bool) -> None:
|
|
66
|
+
"""Restore paused state to what it was before a blocking prompt."""
|
|
67
|
+
self._esc_listener_paused = previous_state
|
|
68
|
+
|
|
69
|
+
async def _listen_for_interrupt_key(self) -> bool:
|
|
70
|
+
"""Listen for interrupt keys (ESC/Ctrl+C) during query execution.
|
|
71
|
+
|
|
72
|
+
Uses raw terminal mode for immediate key detection without waiting
|
|
73
|
+
for escape sequences to complete.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if an interrupt key was pressed.
|
|
77
|
+
"""
|
|
78
|
+
import select
|
|
79
|
+
import termios
|
|
80
|
+
import tty
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
fd = sys.stdin.fileno()
|
|
84
|
+
old_settings = termios.tcgetattr(fd)
|
|
85
|
+
except (OSError, termios.error, ValueError):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
self._stdin_fd = fd
|
|
89
|
+
self._stdin_old_settings = old_settings
|
|
90
|
+
raw_enabled = False
|
|
91
|
+
try:
|
|
92
|
+
while self._esc_listener_active:
|
|
93
|
+
if self._esc_listener_paused:
|
|
94
|
+
if raw_enabled:
|
|
95
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
96
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
97
|
+
raw_enabled = False
|
|
98
|
+
self._stdin_in_raw_mode = False
|
|
99
|
+
await asyncio.sleep(0.05)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
if not raw_enabled:
|
|
103
|
+
tty.setraw(fd)
|
|
104
|
+
raw_enabled = True
|
|
105
|
+
self._stdin_in_raw_mode = True
|
|
106
|
+
|
|
107
|
+
await asyncio.sleep(0.02)
|
|
108
|
+
if select.select([sys.stdin], [], [], 0)[0]:
|
|
109
|
+
if sys.stdin.read(1) in INTERRUPT_KEYS:
|
|
110
|
+
return True
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
pass
|
|
113
|
+
finally:
|
|
114
|
+
self._stdin_in_raw_mode = False
|
|
115
|
+
with contextlib.suppress(OSError, termios.error, ValueError):
|
|
116
|
+
if raw_enabled:
|
|
117
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
118
|
+
self._stdin_fd = None
|
|
119
|
+
self._stdin_old_settings = None
|
|
120
|
+
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
async def _cancel_task(self, task: asyncio.Task) -> None:
|
|
124
|
+
"""Cancel a task and wait for it to finish."""
|
|
125
|
+
if not task.done():
|
|
126
|
+
task.cancel()
|
|
127
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
128
|
+
await task
|
|
129
|
+
|
|
130
|
+
def _trigger_abort(self) -> None:
|
|
131
|
+
"""Signal the query to abort via callback."""
|
|
132
|
+
if self._abort_callback is not None:
|
|
133
|
+
self._abort_callback()
|
|
134
|
+
|
|
135
|
+
async def run_with_interrupt(self, query_coro: Any) -> bool:
|
|
136
|
+
"""Run a coroutine with ESC key interrupt support.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
query_coro: The coroutine to run with interrupt support.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if interrupted, False if completed normally.
|
|
143
|
+
"""
|
|
144
|
+
self._query_interrupted = False
|
|
145
|
+
self._esc_listener_active = True
|
|
146
|
+
|
|
147
|
+
query_task = asyncio.create_task(query_coro)
|
|
148
|
+
interrupt_task = asyncio.create_task(self._listen_for_interrupt_key())
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
done, _ = await asyncio.wait(
|
|
152
|
+
{query_task, interrupt_task},
|
|
153
|
+
return_when=asyncio.FIRST_COMPLETED
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Check if interrupted
|
|
157
|
+
if interrupt_task in done and interrupt_task.result():
|
|
158
|
+
self._query_interrupted = True
|
|
159
|
+
self._trigger_abort()
|
|
160
|
+
await self._cancel_task(query_task)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
# Query completed normally
|
|
164
|
+
if query_task in done:
|
|
165
|
+
await self._cancel_task(interrupt_task)
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
query_task.result()
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
finally:
|
|
173
|
+
self._esc_listener_active = False
|
|
174
|
+
await self._cancel_task(query_task)
|
|
175
|
+
await self._cancel_task(interrupt_task)
|