ripperdoc 0.2.6__py3-none-any.whl → 0.2.8__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.
Files changed (44) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +5 -0
  3. ripperdoc/cli/commands/__init__.py +71 -6
  4. ripperdoc/cli/commands/clear_cmd.py +1 -0
  5. ripperdoc/cli/commands/exit_cmd.py +1 -1
  6. ripperdoc/cli/commands/help_cmd.py +11 -1
  7. ripperdoc/cli/commands/hooks_cmd.py +636 -0
  8. ripperdoc/cli/commands/permissions_cmd.py +36 -34
  9. ripperdoc/cli/commands/resume_cmd.py +71 -37
  10. ripperdoc/cli/ui/file_mention_completer.py +276 -0
  11. ripperdoc/cli/ui/helpers.py +100 -3
  12. ripperdoc/cli/ui/interrupt_handler.py +175 -0
  13. ripperdoc/cli/ui/message_display.py +249 -0
  14. ripperdoc/cli/ui/panels.py +63 -0
  15. ripperdoc/cli/ui/rich_ui.py +233 -648
  16. ripperdoc/cli/ui/tool_renderers.py +2 -2
  17. ripperdoc/core/agents.py +4 -4
  18. ripperdoc/core/custom_commands.py +411 -0
  19. ripperdoc/core/hooks/__init__.py +99 -0
  20. ripperdoc/core/hooks/config.py +303 -0
  21. ripperdoc/core/hooks/events.py +540 -0
  22. ripperdoc/core/hooks/executor.py +498 -0
  23. ripperdoc/core/hooks/integration.py +353 -0
  24. ripperdoc/core/hooks/manager.py +720 -0
  25. ripperdoc/core/providers/anthropic.py +476 -69
  26. ripperdoc/core/query.py +61 -4
  27. ripperdoc/core/query_utils.py +1 -1
  28. ripperdoc/core/tool.py +1 -1
  29. ripperdoc/tools/bash_tool.py +5 -5
  30. ripperdoc/tools/file_edit_tool.py +2 -2
  31. ripperdoc/tools/file_read_tool.py +2 -2
  32. ripperdoc/tools/multi_edit_tool.py +1 -1
  33. ripperdoc/utils/conversation_compaction.py +476 -0
  34. ripperdoc/utils/message_compaction.py +109 -154
  35. ripperdoc/utils/message_formatting.py +216 -0
  36. ripperdoc/utils/messages.py +31 -9
  37. ripperdoc/utils/path_ignore.py +3 -4
  38. ripperdoc/utils/session_history.py +19 -7
  39. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
  40. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
  41. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
  42. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
  43. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
  44. {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
@@ -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) -> None:
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)
@@ -0,0 +1,249 @@
1
+ """Message display and rendering utilities for RichUI.
2
+
3
+ This module handles rendering conversation messages to the terminal, including:
4
+ - Tool call and result formatting
5
+ - Assistant/user message display
6
+ - Reasoning block rendering
7
+ """
8
+
9
+ from typing import Any, Callable, List, Optional, Tuple, Union
10
+
11
+ from rich.console import Console
12
+ from rich.markdown import Markdown
13
+ from rich.markup import escape
14
+
15
+ from ripperdoc.cli.ui.tool_renderers import ToolResultRendererRegistry
16
+ from ripperdoc.utils.messages import UserMessage, AssistantMessage, ProgressMessage
17
+ from ripperdoc.utils.message_formatting import format_reasoning_preview
18
+
19
+ ConversationMessage = Union[UserMessage, AssistantMessage, ProgressMessage]
20
+
21
+
22
+ class MessageDisplay:
23
+ """Handles message rendering and display operations."""
24
+
25
+ def __init__(self, console: Console, verbose: bool = False):
26
+ """Initialize the message display handler.
27
+
28
+ Args:
29
+ console: Rich console for output
30
+ verbose: Whether to show verbose output
31
+ """
32
+ self.console = console
33
+ self.verbose = verbose
34
+
35
+ def format_tool_args(self, tool_name: str, tool_args: Optional[dict]) -> List[str]:
36
+ """Render tool arguments into concise display-friendly parts."""
37
+ if not tool_args:
38
+ return []
39
+
40
+ args_parts: List[str] = []
41
+
42
+ def _format_arg(arg_key: str, arg_value: Any) -> str:
43
+ if arg_key == "todos" and isinstance(arg_value, list):
44
+ counts = {"pending": 0, "in_progress": 0, "completed": 0}
45
+ for item in arg_value:
46
+ status = ""
47
+ if isinstance(item, dict):
48
+ status = item.get("status", "")
49
+ elif hasattr(item, "get"):
50
+ status = item.get("status", "")
51
+ elif hasattr(item, "status"):
52
+ status = getattr(item, "status")
53
+ if status in counts:
54
+ counts[status] += 1
55
+ total = len(arg_value)
56
+ return f"{arg_key}: {total} items"
57
+ if isinstance(arg_value, (list, dict)):
58
+ return f"{arg_key}: {len(arg_value)} items"
59
+ if isinstance(arg_value, str) and len(arg_value) > 50:
60
+ return f'{arg_key}: "{arg_value[:50]}..."'
61
+ return f"{arg_key}: {arg_value}"
62
+
63
+ if tool_name == "Bash":
64
+ command_value = tool_args.get("command")
65
+ if command_value is not None:
66
+ args_parts.append(_format_arg("command", command_value))
67
+
68
+ background_value = tool_args.get("run_in_background", tool_args.get("runInBackground"))
69
+ background_value = bool(background_value) if background_value is not None else False
70
+ args_parts.append(f"background: {background_value}")
71
+
72
+ sandbox_value = tool_args.get("sandbox")
73
+ sandbox_value = bool(sandbox_value) if sandbox_value is not None else False
74
+ args_parts.append(f"sandbox: {sandbox_value}")
75
+
76
+ for key, value in tool_args.items():
77
+ if key in {"command", "run_in_background", "runInBackground", "sandbox"}:
78
+ continue
79
+ args_parts.append(_format_arg(key, value))
80
+ return args_parts
81
+
82
+ # Special handling for Edit and MultiEdit tools - don't show old_string
83
+ if tool_name in ["Edit", "MultiEdit"]:
84
+ for key, value in tool_args.items():
85
+ if key == "new_string":
86
+ continue # Skip new_string for Edit/MultiEdit tools
87
+ if key == "old_string":
88
+ continue # Skip old_string for Edit/MultiEdit tools
89
+ # For MultiEdit, also handle edits array
90
+ if key == "edits" and isinstance(value, list):
91
+ args_parts.append(f"edits: {len(value)} operations")
92
+ continue
93
+ args_parts.append(_format_arg(key, value))
94
+ return args_parts
95
+
96
+ for key, value in tool_args.items():
97
+ args_parts.append(_format_arg(key, value))
98
+ return args_parts
99
+
100
+ def print_tool_call(self, sender: str, content: str, tool_args: Optional[dict]) -> None:
101
+ """Render a tool invocation line."""
102
+ if sender == "Task":
103
+ subagent = ""
104
+ if isinstance(tool_args, dict):
105
+ subagent = tool_args.get("subagent_type") or tool_args.get("subagent") or ""
106
+ desc = ""
107
+ if isinstance(tool_args, dict):
108
+ raw_desc = tool_args.get("description") or tool_args.get("prompt") or ""
109
+ desc = raw_desc if len(str(raw_desc)) <= 120 else str(raw_desc)[:117] + "..."
110
+ label = f"-> Launching subagent: {subagent or 'unknown'}"
111
+ if desc:
112
+ label += f" — {desc}"
113
+ self.console.print(f"[cyan]{escape(label)}[/cyan]")
114
+ return
115
+
116
+ tool_name = sender if sender != "Ripperdoc" else content
117
+ tool_display = f"● {tool_name}("
118
+
119
+ args_parts = self.format_tool_args(tool_name, tool_args)
120
+ if args_parts:
121
+ tool_display += ", ".join(args_parts)
122
+ tool_display += ")"
123
+
124
+ self.console.print(f"[dim cyan]{escape(tool_display)}[/]")
125
+
126
+ def print_tool_result(
127
+ self,
128
+ sender: str,
129
+ content: str,
130
+ tool_data: Any,
131
+ tool_error: bool = False,
132
+ parse_bash_output_fn: Optional[Callable] = None,
133
+ ) -> None:
134
+ """Render a tool result summary using the renderer registry."""
135
+ # Check for failure states
136
+ failed = tool_error
137
+ if tool_data is not None:
138
+ if isinstance(tool_data, dict):
139
+ failed = failed or (tool_data.get("success") is False)
140
+ else:
141
+ success = getattr(tool_data, "success", None)
142
+ failed = failed or (success is False)
143
+ failed = failed or bool(self._get_tool_field(tool_data, "is_error"))
144
+
145
+ # Extract warning/token info
146
+ warning_text = None
147
+ token_estimate = None
148
+ if tool_data is not None:
149
+ warning_text = self._get_tool_field(tool_data, "warning")
150
+ token_estimate = self._get_tool_field(tool_data, "token_estimate")
151
+
152
+ # Handle failure case
153
+ if failed:
154
+ if content:
155
+ self.console.print(f" ⎿ [red]{escape(content)}[/red]")
156
+ else:
157
+ self.console.print(f" ⎿ [red]{escape(sender)} failed[/red]")
158
+ return
159
+
160
+ # Display warnings and token estimates
161
+ if warning_text:
162
+ self.console.print(f" ⎿ [yellow]{escape(str(warning_text))}[/yellow]")
163
+ if token_estimate:
164
+ self.console.print(
165
+ f" [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
166
+ )
167
+ elif token_estimate and self.verbose:
168
+ self.console.print(f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]")
169
+
170
+ # Handle empty content
171
+ if not content:
172
+ self.console.print(" ⎿ [dim]Tool completed[/]")
173
+ return
174
+
175
+ # Use renderer registry for tool-specific rendering
176
+ registry = ToolResultRendererRegistry(
177
+ self.console, self.verbose, parse_bash_output_fn or self._default_parse_bash
178
+ )
179
+ if registry.render(sender, content, tool_data):
180
+ return
181
+
182
+ # Fallback for unhandled tools
183
+ self.console.print(" ⎿ [dim]Tool completed[/]")
184
+
185
+ def print_generic_tool(self, sender: str, content: str) -> None:
186
+ """Fallback rendering for miscellaneous tool messages."""
187
+ if sender == "Task" and isinstance(content, str) and content.startswith("[subagent:"):
188
+ agent_label = content.split("]", 1)[0].replace("[subagent:", "").strip()
189
+ summary = content.split("]", 1)[1].strip() if "]" in content else ""
190
+ self.console.print(f"[green]↳ Subagent {escape(agent_label)} finished[/green]")
191
+ if summary:
192
+ self.console.print(f" {summary}", markup=False)
193
+ return
194
+ self.console.print(f"[dim cyan][Tool] {escape(sender)}: {escape(content)}[/]")
195
+
196
+ def print_human_or_assistant(self, sender: str, content: str) -> None:
197
+ """Render messages from the user or assistant."""
198
+ if sender.lower() == "you":
199
+ self.console.print(f"[bold green]{escape(sender)}:[/] {escape(content)}")
200
+ return
201
+ self.console.print(Markdown(content))
202
+
203
+ def _get_tool_field(self, data: Any, key: str, default: Any = None) -> Any:
204
+ """Safely fetch a field from either an object or a dict."""
205
+ if isinstance(data, dict):
206
+ return data.get(key, default)
207
+ return getattr(data, key, default)
208
+
209
+ def _default_parse_bash(self, content: str) -> Tuple[List[str], List[str]]:
210
+ """Default bash output parser."""
211
+ return parse_bash_output_sections(content)
212
+
213
+ def print_reasoning(self, reasoning: Any) -> None:
214
+ """Display a collapsed preview of reasoning/thinking blocks."""
215
+ preview = format_reasoning_preview(reasoning)
216
+ if preview:
217
+ self.console.print(f"[dim italic]Thinking: {escape(preview)}[/]")
218
+
219
+
220
+ def parse_bash_output_sections(content: str) -> Tuple[List[str], List[str]]:
221
+ """Parse stdout/stderr sections from a bash output text block."""
222
+ stdout_lines: List[str] = []
223
+ stderr_lines: List[str] = []
224
+ if not content:
225
+ return stdout_lines, stderr_lines
226
+
227
+ current: Optional[str] = None
228
+ for line in content.splitlines():
229
+ stripped = line.strip()
230
+ if stripped.startswith("stdout:"):
231
+ current = "stdout"
232
+ remainder = line.split("stdout:", 1)[1].strip()
233
+ if remainder:
234
+ stdout_lines.append(remainder)
235
+ continue
236
+ if stripped.startswith("stderr:"):
237
+ current = "stderr"
238
+ remainder = line.split("stderr:", 1)[1].strip()
239
+ if remainder:
240
+ stderr_lines.append(remainder)
241
+ continue
242
+ if stripped.startswith("exit code:"):
243
+ break
244
+ if current == "stdout":
245
+ stdout_lines.append(line)
246
+ elif current == "stderr":
247
+ stderr_lines.append(line)
248
+
249
+ return stdout_lines, stderr_lines
@@ -0,0 +1,63 @@
1
+ """UI panels and visual components for RichUI.
2
+
3
+ This module contains welcome panels, status bars, and other
4
+ visual UI elements.
5
+ """
6
+
7
+ from typing import List, Tuple
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.text import Text
12
+ from rich import box
13
+
14
+ from ripperdoc import __version__
15
+ from ripperdoc.cli.ui.helpers import get_profile_for_pointer
16
+
17
+
18
+ def create_welcome_panel() -> Panel:
19
+ """Create a welcome panel for the CLI startup."""
20
+ welcome_content = """
21
+ [bold cyan]Welcome to Ripperdoc![/bold cyan]
22
+
23
+ Ripperdoc is an AI-powered coding assistant that helps with software development tasks.
24
+ You can read files, edit code, run commands, and help with various programming tasks.
25
+
26
+ [dim]Type your questions below. Press Ctrl+C to exit.[/dim]
27
+ """
28
+
29
+ return Panel(
30
+ welcome_content,
31
+ title=f"Ripperdoc v{__version__}",
32
+ border_style="cyan",
33
+ box=box.ROUNDED,
34
+ padding=(1, 2),
35
+ )
36
+
37
+
38
+ def create_status_bar() -> Text:
39
+ """Create a status bar with current model information."""
40
+ profile = get_profile_for_pointer("main")
41
+ model_name = profile.model if profile else "Not configured"
42
+
43
+ status_text = Text()
44
+ status_text.append("Ripperdoc", style="bold cyan")
45
+ status_text.append(" • ")
46
+ status_text.append(model_name, style="dim")
47
+ status_text.append(" • ")
48
+ status_text.append("Ready", style="green")
49
+
50
+ return status_text
51
+
52
+
53
+ def print_shortcuts(console: Console) -> None:
54
+ """Show common keyboard shortcuts and prefixes."""
55
+ pairs: List[Tuple[str, str]] = [
56
+ ("? for shortcuts", "! for bash mode"),
57
+ ("/ for commands", "@ for file mention"),
58
+ ]
59
+ console.print("[dim]Shortcuts[/dim]")
60
+ for left, right in pairs:
61
+ left_text = f" {left}".ljust(32)
62
+ right_text = f"{right}" if right else ""
63
+ console.print(f"{left_text}{right_text}")