ripperdoc 0.2.10__py3-none-any.whl → 0.3.0__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 (70) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  17. ripperdoc/cli/ui/panels.py +13 -8
  18. ripperdoc/cli/ui/rich_ui.py +451 -32
  19. ripperdoc/cli/ui/spinner.py +68 -5
  20. ripperdoc/cli/ui/tool_renderers.py +10 -9
  21. ripperdoc/cli/ui/wizard.py +18 -11
  22. ripperdoc/core/agents.py +4 -0
  23. ripperdoc/core/config.py +235 -0
  24. ripperdoc/core/default_tools.py +1 -0
  25. ripperdoc/core/hooks/llm_callback.py +0 -1
  26. ripperdoc/core/hooks/manager.py +6 -0
  27. ripperdoc/core/permissions.py +82 -5
  28. ripperdoc/core/providers/openai.py +55 -9
  29. ripperdoc/core/query.py +349 -108
  30. ripperdoc/core/query_utils.py +17 -14
  31. ripperdoc/core/skills.py +1 -0
  32. ripperdoc/core/theme.py +298 -0
  33. ripperdoc/core/tool.py +8 -3
  34. ripperdoc/protocol/__init__.py +14 -0
  35. ripperdoc/protocol/models.py +300 -0
  36. ripperdoc/protocol/stdio.py +1453 -0
  37. ripperdoc/tools/background_shell.py +49 -5
  38. ripperdoc/tools/bash_tool.py +75 -9
  39. ripperdoc/tools/file_edit_tool.py +98 -29
  40. ripperdoc/tools/file_read_tool.py +139 -8
  41. ripperdoc/tools/file_write_tool.py +46 -3
  42. ripperdoc/tools/grep_tool.py +98 -8
  43. ripperdoc/tools/lsp_tool.py +9 -15
  44. ripperdoc/tools/multi_edit_tool.py +26 -3
  45. ripperdoc/tools/skill_tool.py +52 -1
  46. ripperdoc/tools/task_tool.py +33 -8
  47. ripperdoc/utils/file_watch.py +12 -6
  48. ripperdoc/utils/image_utils.py +125 -0
  49. ripperdoc/utils/log.py +30 -3
  50. ripperdoc/utils/lsp.py +9 -3
  51. ripperdoc/utils/mcp.py +80 -18
  52. ripperdoc/utils/message_formatting.py +2 -2
  53. ripperdoc/utils/messages.py +177 -32
  54. ripperdoc/utils/pending_messages.py +50 -0
  55. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  56. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  57. ripperdoc/utils/platform.py +198 -0
  58. ripperdoc/utils/session_heatmap.py +1 -3
  59. ripperdoc/utils/session_history.py +2 -2
  60. ripperdoc/utils/session_stats.py +1 -0
  61. ripperdoc/utils/shell_utils.py +8 -5
  62. ripperdoc/utils/todo.py +0 -6
  63. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
  65. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  66. ripperdoc/sdk/__init__.py +0 -9
  67. ripperdoc/sdk/client.py +0 -408
  68. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  69. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  70. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,7 @@ import asyncio
6
6
  from collections import defaultdict
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
9
- from typing import Any, Awaitable, Callable, Optional, Set
9
+ from typing import Any, Awaitable, Callable, Optional, Set, TYPE_CHECKING, TYPE_CHECKING as TYPE_CHECKING
10
10
 
11
11
  from ripperdoc.core.config import config_manager
12
12
  from ripperdoc.core.hooks.manager import hook_manager
@@ -14,6 +14,10 @@ from ripperdoc.core.tool import Tool
14
14
  from ripperdoc.utils.permissions import PermissionDecision, ToolRule
15
15
  from ripperdoc.utils.log import get_logger
16
16
 
17
+ if TYPE_CHECKING:
18
+ from rich.console import Console
19
+ from prompt_toolkit import PromptSession
20
+
17
21
  logger = get_logger()
18
22
 
19
23
 
@@ -89,7 +93,7 @@ def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
89
93
 
90
94
 
91
95
  def _render_options_prompt(prompt: str, options: list[tuple[str, str]]) -> str:
92
- """Render a simple numbered prompt."""
96
+ """Render a simple numbered prompt (fallback for non-Rich environments)."""
93
97
  border = "─" * 120
94
98
  lines = [border, prompt, ""]
95
99
  for idx, (_, label) in enumerate(options, start=1):
@@ -101,6 +105,42 @@ def _render_options_prompt(prompt: str, options: list[tuple[str, str]]) -> str:
101
105
  return "\n".join(lines)
102
106
 
103
107
 
108
+ def _render_options_prompt_rich(
109
+ console: "Console",
110
+ prompt: str,
111
+ options: list[tuple[str, str]]
112
+ ) -> None:
113
+ """Render permission dialog using Rich Panel for better visual consistency."""
114
+ from rich.panel import Panel
115
+ from rich.text import Text
116
+
117
+ # Build option lines with markup
118
+ option_lines = []
119
+ for idx, (_, label) in enumerate(options, start=1):
120
+ prefix = "[cyan]❯[/cyan]" if idx == 1 else " "
121
+ option_lines.append(f"{prefix} {idx}. {label}")
122
+
123
+ numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
124
+ shortcut_choices = "/".join(opt[0] for opt in options)
125
+
126
+ # Build the prompt content as a markup string
127
+ markup_content = f"{prompt}\n\n" + "\n".join(option_lines) + "\n"
128
+ markup_content += f"Choice ([cyan]{numeric_choices}[/cyan] or [cyan]{shortcut_choices}[/cyan]): "
129
+
130
+ # Parse markup to create a Text object
131
+ content = Text.from_markup(markup_content)
132
+
133
+ # Render the panel
134
+ panel = Panel(
135
+ content,
136
+ title=Text.from_markup("[yellow]Permission Required[/yellow]"),
137
+ title_align="left",
138
+ border_style="yellow",
139
+ padding=(0, 1),
140
+ )
141
+ console.print(panel)
142
+
143
+
104
144
  def _rule_strings(rule_suggestions: Optional[Any]) -> list[str]:
105
145
  """Normalize rule suggestions to simple strings."""
106
146
  if not rule_suggestions:
@@ -118,9 +158,18 @@ def make_permission_checker(
118
158
  project_path: Path,
119
159
  yolo_mode: bool,
120
160
  prompt_fn: Optional[Callable[[str], str]] = None,
161
+ console: Optional["Console"] = None,
162
+ prompt_session: Optional["PromptSession"] = None,
121
163
  ) -> Callable[[Tool[Any, Any], Any], Awaitable[PermissionResult]]:
122
164
  """Create a permission checking function for the current project.
123
165
 
166
+ Args:
167
+ project_path: Path to the project directory
168
+ yolo_mode: If True, all tool calls are allowed without prompting
169
+ prompt_fn: Optional function to use for prompting (defaults to input())
170
+ console: Optional Rich console for rich permission dialogs
171
+ prompt_session: Optional PromptSession for better interrupt handling
172
+
124
173
  In yolo mode, all tool calls are allowed without prompting.
125
174
  """
126
175
 
@@ -131,13 +180,41 @@ def make_permission_checker(
131
180
  session_tool_rules: dict[str, Set[str]] = defaultdict(set)
132
181
 
133
182
  async def _prompt_user(prompt: str, options: list[tuple[str, str]]) -> str:
134
- """Prompt the user without blocking the event loop."""
183
+ """Prompt the user with proper interrupt handling."""
184
+ # Build the prompt message
185
+ if console is not None:
186
+ # Use Rich Panel for the dialog
187
+ _render_options_prompt_rich(console, prompt, options)
188
+ # Build simple prompt for the input line
189
+ numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
190
+ shortcut_choices = "/".join(opt[0] for opt in options)
191
+ input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
192
+ else:
193
+ # Use plain text rendering
194
+ rendered = _render_options_prompt(prompt, options)
195
+ input_prompt = rendered
196
+
197
+ # Try to use PromptSession if available (better interrupt handling)
198
+ if prompt_session is not None:
199
+ try:
200
+ # PromptSession.prompt() can handle Ctrl+C gracefully
201
+ return await prompt_session.prompt_async(input_prompt)
202
+ except KeyboardInterrupt:
203
+ logger.debug("[permissions] KeyboardInterrupt in prompt_session")
204
+ return "n"
205
+ except EOFError:
206
+ logger.debug("[permissions] EOFError in prompt_session")
207
+ return "n"
208
+
209
+ # Fallback to simple input() via executor
135
210
  loop = asyncio.get_running_loop()
136
211
  responder = prompt_fn or input
137
212
 
138
213
  def _ask() -> str:
139
- rendered = _render_options_prompt(prompt, options)
140
- return responder(rendered)
214
+ try:
215
+ return responder(input_prompt)
216
+ except (KeyboardInterrupt, EOFError):
217
+ return "n"
141
218
 
142
219
  return await loop.run_in_executor(None, _ask)
143
220
 
@@ -80,10 +80,18 @@ def _effort_from_tokens(max_thinking_tokens: int) -> Optional[str]:
80
80
 
81
81
 
82
82
  def _detect_openai_vendor(model_profile: ModelProfile) -> str:
83
- """Best-effort vendor hint for OpenAI-compatible endpoints."""
83
+ """Best-effort vendor hint for OpenAI-compatible endpoints.
84
+
85
+ If thinking_mode is explicitly set to "none" or "disabled", returns "none"
86
+ to skip all thinking protocol handling.
87
+ """
84
88
  override = getattr(model_profile, "thinking_mode", None)
85
89
  if isinstance(override, str) and override.strip():
86
- return override.strip().lower()
90
+ mode = override.strip().lower()
91
+ # Allow explicit disable of thinking protocol
92
+ if mode in ("disabled", "off"):
93
+ return "none"
94
+ return mode
87
95
  base = (model_profile.api_base or "").lower()
88
96
  name = (model_profile.model or "").lower()
89
97
  if "openrouter.ai" in base:
@@ -106,21 +114,25 @@ def _build_thinking_kwargs(
106
114
  extra_body: Dict[str, Any] = {}
107
115
  top_level: Dict[str, Any] = {}
108
116
  vendor = _detect_openai_vendor(model_profile)
117
+
118
+ # Skip thinking protocol if explicitly disabled
119
+ if vendor == "none":
120
+ return extra_body, top_level
121
+
109
122
  effort = _effort_from_tokens(max_thinking_tokens)
110
123
 
111
124
  if vendor == "deepseek":
112
125
  if max_thinking_tokens != 0:
113
126
  extra_body["thinking"] = {"type": "enabled"}
114
127
  elif vendor == "qwen":
128
+ # Only send enable_thinking when explicitly enabling thinking mode
129
+ # Some qwen-compatible APIs don't support this parameter
115
130
  if max_thinking_tokens > 0:
116
131
  extra_body["enable_thinking"] = True
117
- elif max_thinking_tokens == 0:
118
- extra_body["enable_thinking"] = False
119
132
  elif vendor == "openrouter":
133
+ # Only send reasoning when explicitly enabling thinking mode
120
134
  if max_thinking_tokens > 0:
121
135
  extra_body["reasoning"] = {"max_tokens": max_thinking_tokens}
122
- elif max_thinking_tokens == 0:
123
- extra_body["reasoning"] = {"effort": "none"}
124
136
  elif vendor == "gemini_openai":
125
137
  google_cfg: Dict[str, Any] = {}
126
138
  if max_thinking_tokens > 0:
@@ -250,6 +262,15 @@ class OpenAIClient(ProviderClient):
250
262
  model_profile, max_thinking_tokens
251
263
  )
252
264
 
265
+ logger.debug(
266
+ "[openai_client] Starting API request",
267
+ extra={
268
+ "model": model_profile.model,
269
+ "api_base": model_profile.api_base,
270
+ "request_timeout": request_timeout,
271
+ },
272
+ )
273
+
253
274
  logger.debug(
254
275
  "[openai_client] Request parameters",
255
276
  extra={
@@ -420,12 +441,13 @@ class OpenAIClient(ProviderClient):
420
441
  )
421
442
 
422
443
  if (
423
- can_stream_text
444
+ can_stream
424
445
  and not collected_text
425
446
  and not streamed_tool_calls
426
447
  and not streamed_tool_text
448
+ and not stream_reasoning_text
427
449
  ):
428
- logger.debug(
450
+ logger.warning(
429
451
  "[openai_client] Streaming returned no content; retrying without stream",
430
452
  extra={"model": model_profile.model},
431
453
  )
@@ -450,6 +472,30 @@ class OpenAIClient(ProviderClient):
450
472
  if not can_stream and (
451
473
  not openai_response or not getattr(openai_response, "choices", None)
452
474
  ):
475
+ # Check for non-standard error response (e.g., iflow returns HTTP 200 with error JSON)
476
+ error_msg = (
477
+ getattr(openai_response, "msg", None)
478
+ or getattr(openai_response, "message", None)
479
+ or getattr(openai_response, "error", None)
480
+ )
481
+ error_status = getattr(openai_response, "status", None)
482
+ if error_msg or error_status:
483
+ error_text = f"API Error: {error_msg or 'Unknown error'}"
484
+ if error_status:
485
+ error_text = f"API Error ({error_status}): {error_msg or 'Unknown error'}"
486
+ logger.error(
487
+ "[openai_client] Non-standard error response from API",
488
+ extra={
489
+ "model": model_profile.model,
490
+ "error_status": error_status,
491
+ "error_msg": error_msg,
492
+ },
493
+ )
494
+ return ProviderResponse.create_error(
495
+ error_code="api_error",
496
+ error_message=error_text,
497
+ duration_ms=duration_ms,
498
+ )
453
499
  logger.warning(
454
500
  "[openai_client] No choices returned from OpenAI response",
455
501
  extra={"model": model_profile.model},
@@ -532,7 +578,7 @@ class OpenAIClient(ProviderClient):
532
578
  },
533
579
  )
534
580
 
535
- logger.info(
581
+ logger.debug(
536
582
  "[openai_client] Response received",
537
583
  extra={
538
584
  "model": model_profile.model,