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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """Ripperdoc - AI-powered coding agent."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.7"
@@ -12,6 +12,7 @@ command = SlashCommand(
12
12
  name="clear",
13
13
  description="Clear conversation history",
14
14
  handler=_handle,
15
+ aliases=("new",)
15
16
  )
16
17
 
17
18
 
@@ -12,7 +12,7 @@ command = SlashCommand(
12
12
  name="exit",
13
13
  description="Exit Ripperdoc",
14
14
  handler=_handle,
15
- aliases=(),
15
+ aliases=("quit",),
16
16
  )
17
17
 
18
18
 
@@ -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 a numeric arg is provided, try to resolve immediately.
29
+ # If arg is provided, treat it as session id prefix match
27
30
  if arg.strip():
28
- if arg.isdigit():
29
- idx = int(arg)
30
- if 0 <= idx < len(sessions):
31
- return sessions[idx]
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"[red]Invalid session index {escape(str(idx))}. "
34
- f"Choose 0-{len(sessions) - 1}.[/red]"
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
- ui.console.print("\n[bold]Saved sessions:[/bold]")
45
- for idx, summary in enumerate(sessions):
46
- ui.console.print(
47
- f" [{idx}] {summary.session_id} "
48
- f"({summary.message_count} messages, "
49
- f"{_format_time(summary.created_at)} {_format_time(summary.updated_at)}) "
50
- f"{escape(summary.first_prompt)}",
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
- idx = int(choice_text)
62
- if idx < 0 or idx >= len(sessions):
63
- ui.console.print(
64
- f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
65
- )
66
- return None
67
- return sessions[idx]
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
@@ -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
- from typing import Optional
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)