ripperdoc 0.3.1__py3-none-any.whl → 0.3.2__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 (37) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -1
  3. ripperdoc/cli/commands/agents_cmd.py +93 -53
  4. ripperdoc/cli/commands/mcp_cmd.py +3 -0
  5. ripperdoc/cli/commands/models_cmd.py +768 -283
  6. ripperdoc/cli/commands/permissions_cmd.py +107 -52
  7. ripperdoc/cli/commands/resume_cmd.py +61 -51
  8. ripperdoc/cli/commands/themes_cmd.py +31 -1
  9. ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
  10. ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
  11. ripperdoc/cli/ui/choice.py +376 -0
  12. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  13. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  14. ripperdoc/cli/ui/panels.py +19 -4
  15. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  16. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  17. ripperdoc/cli/ui/provider_options.py +220 -80
  18. ripperdoc/cli/ui/rich_ui.py +9 -11
  19. ripperdoc/cli/ui/tips.py +89 -0
  20. ripperdoc/cli/ui/wizard.py +98 -45
  21. ripperdoc/core/config.py +3 -0
  22. ripperdoc/core/permissions.py +25 -70
  23. ripperdoc/core/providers/anthropic.py +11 -0
  24. ripperdoc/protocol/stdio.py +3 -1
  25. ripperdoc/tools/bash_tool.py +2 -0
  26. ripperdoc/tools/file_edit_tool.py +100 -181
  27. ripperdoc/tools/file_read_tool.py +101 -25
  28. ripperdoc/tools/multi_edit_tool.py +239 -91
  29. ripperdoc/tools/notebook_edit_tool.py +11 -29
  30. ripperdoc/utils/file_editing.py +164 -0
  31. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  32. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  33. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
  34. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  35. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  36. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  37. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,376 @@
1
+ """Unified choice UI component for Ripperdoc.
2
+
3
+ This module provides a reusable, visually consistent choice interface
4
+ that can be used across onboarding, permission prompts, and other
5
+ user interactions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import html
11
+ import shutil
12
+ from typing import Any, Optional
13
+
14
+ from prompt_toolkit.filters import is_done
15
+ from prompt_toolkit.formatted_text import HTML, fragment_list_to_text, to_formatted_text
16
+ from prompt_toolkit.key_binding import KeyBindings
17
+ from prompt_toolkit.shortcuts import choice
18
+ from prompt_toolkit.styles import Style
19
+ from prompt_toolkit.utils import get_cwidth
20
+
21
+
22
+ # Shared style system for all choice prompts
23
+ def _choice_style() -> Style:
24
+ """Create the unified style for choice prompts.
25
+
26
+ Uses a golden/amber theme with cyan accents for consistent branding.
27
+ """
28
+ return Style.from_dict(
29
+ {
30
+ "frame.border": "#d4a017", # Golden/amber border
31
+ "selected-option": "bold",
32
+ "option": "#5fd7ff", # Cyan for unselected options
33
+ "title": "#ffaf00", # Orange/amber for titles
34
+ "description": "#ffffff", # White for descriptions
35
+ "question": "#ffd700", # Gold for questions
36
+ "label": "#87afff", # Light blue for field labels
37
+ "warning": "#ff5555", # Red for warnings
38
+ "info": "#5fd7ff", # Cyan for info text
39
+ "dim": "#626262", # Dimmed text
40
+ "yes-option": "#ffffff", # Neutral for Yes options
41
+ "no-option": "#ffffff", # Neutral for No options
42
+ "value": "#f8f8f2", # Off-white for values
43
+ "default": "#50fa7b", # Green for defaults
44
+ "marker": "#ffb86c", # Orange for markers (→, etc.)
45
+ }
46
+ )
47
+
48
+
49
+ def onboarding_style() -> Style:
50
+ """Create the style for onboarding prompts.
51
+
52
+ Uses a balanced theme with purple accents for a modern setup experience.
53
+ """
54
+ return Style.from_dict(
55
+ {
56
+ "frame.border": "#8b9dc3", # Soft silver-blue border
57
+ "selected-option": "bold",
58
+ "option": "#f8f8f2", # Off-white for options (cleaner)
59
+ "title": "#bd93f9", # Soft purple for titles
60
+ "description": "#f0f0f0", # Off-white for descriptions
61
+ "question": "#ff79c6", # Pink for questions
62
+ "label": "#8be9fd", # Cyan for labels
63
+ "warning": "#ffb86c", # Orange for warnings
64
+ "info": "#8be9fd", # Cyan for info text
65
+ "dim": "#626262", # Dimmed text
66
+ "yes-option": "#ffffff",
67
+ "no-option": "#ffffff",
68
+ "value": "#f8f8f2",
69
+ "default": "#50fa7b", # Green for defaults
70
+ "marker": "#ff79c6", # Pink for markers
71
+ }
72
+ )
73
+
74
+
75
+ def theme_style() -> Style:
76
+ """Create the style for theme selection prompts.
77
+
78
+ Uses a subtle gray border for a clean, minimal appearance.
79
+ """
80
+ return Style.from_dict(
81
+ {
82
+ "frame.border": "#626262", # Subtle gray border
83
+ "selected-option": "bold",
84
+ "option": "#f8f8f2",
85
+ "title": "#f8f8f2", # Off-white for titles
86
+ "description": "#f0f0f0",
87
+ "question": "#f8f8f2",
88
+ "label": "#8be9fd",
89
+ "warning": "#ffb86c",
90
+ "info": "#5fd7ff",
91
+ "dim": "#626262",
92
+ "yes-option": "#ffffff",
93
+ "no-option": "#ffffff",
94
+ "value": "#f8f8f2",
95
+ "default": "#50fa7b",
96
+ "marker": "#8be9fd",
97
+ }
98
+ )
99
+
100
+
101
+ class ChoiceOption:
102
+ """Represents a single choice option.
103
+
104
+ Args:
105
+ value: The value to return when this option is selected
106
+ label: The display label (can contain HTML tags)
107
+ description: Optional description text
108
+ is_default: Whether this is the default choice
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ value: str,
114
+ label: str,
115
+ description: Optional[str] = None,
116
+ is_default: bool = False,
117
+ ):
118
+ self.value = value
119
+ self.label = label
120
+ self.description = description
121
+ self.is_default = is_default
122
+
123
+ def __repr__(self) -> str:
124
+ return f"ChoiceOption(value={self.value!r}, label={self.label!r})"
125
+
126
+
127
+ def _terminal_width(default: int = 80) -> int:
128
+ """Return the current terminal width with a reasonable fallback."""
129
+ try:
130
+ width = shutil.get_terminal_size(fallback=(default, 24)).columns
131
+ except OSError:
132
+ width = default
133
+ return max(1, width)
134
+
135
+
136
+ def _count_display_lines(text: Any, terminal_width: int) -> int:
137
+ """Estimate the number of lines a formatted text will occupy.
138
+
139
+ Args:
140
+ text: The text to measure (plain or formatted)
141
+ terminal_width: Terminal width for word wrapping
142
+
143
+ Returns:
144
+ Estimated number of lines
145
+ """
146
+ if not text or terminal_width <= 0:
147
+ return 0
148
+
149
+ fragments = to_formatted_text(text)
150
+ plain_text = fragment_list_to_text(fragments)
151
+ if not plain_text:
152
+ return 0
153
+
154
+ # Normalize newlines and keep trailing empty lines.
155
+ plain_text = plain_text.replace("\r\n", "\n").replace("\r", "\n")
156
+ lines = plain_text.split("\n")
157
+
158
+ # Account for text wrapping using display width (CJK-safe).
159
+ total_lines = 0
160
+ for line in lines:
161
+ if not line:
162
+ total_lines += 1
163
+ else:
164
+ width = get_cwidth(line)
165
+ wrapped = (width + terminal_width - 1) // terminal_width
166
+ total_lines += max(1, wrapped)
167
+
168
+ return total_lines
169
+
170
+
171
+ def prompt_choice(
172
+ message: str,
173
+ options: list[ChoiceOption] | list[tuple[str, str]],
174
+ *,
175
+ title: Optional[str] = None,
176
+ description: Optional[str] = None,
177
+ warning: Optional[str] = None,
178
+ allow_esc: bool = True,
179
+ esc_value: Optional[str] = None,
180
+ style: Optional[Style] = None,
181
+ ) -> str:
182
+ """Prompt the user to make a choice from a list of options.
183
+
184
+ This is the unified choice interface used across Ripperdoc for
185
+ consistent user experience in onboarding, permission prompts, etc.
186
+
187
+ Args:
188
+ message: The main prompt message (supports HTML formatting)
189
+ options: List of ChoiceOption objects or (value, label) tuples
190
+ title: Optional title to display above the prompt
191
+ description: Optional description text
192
+ warning: Optional warning message to display
193
+ allow_esc: Whether ESC key can be used to cancel (defaults to True)
194
+ esc_value: Value to return when ESC is pressed (defaults to first option's value)
195
+ style: Optional custom style (defaults to unified choice style)
196
+
197
+ Returns:
198
+ The value of the selected option
199
+
200
+ Example:
201
+ ```python
202
+ # Simple usage with tuples
203
+ result = prompt_choice(
204
+ "Choose a provider",
205
+ [("openai", "OpenAI"), ("anthropic", "Anthropic")]
206
+ )
207
+
208
+ # Rich usage with ChoiceOption objects
209
+ result = prompt_choice(
210
+ "Choose a provider",
211
+ [
212
+ ChoiceOption("openai", "<info>OpenAI</info>", "GPT models"),
213
+ ChoiceOption("anthropic", "<info>Anthropic</info>", "Claude models"),
214
+ ],
215
+ title="AI Provider Selection",
216
+ description="Select your preferred AI model provider"
217
+ )
218
+ ```
219
+ """
220
+ # Normalize options to ChoiceOption objects
221
+ choice_options: list[ChoiceOption] = []
222
+ for opt in options:
223
+ if isinstance(opt, ChoiceOption):
224
+ choice_options.append(opt)
225
+ else:
226
+ value, label = opt
227
+ choice_options.append(ChoiceOption(value, label))
228
+
229
+ # Build formatted prompt HTML
230
+ prompt_html = ""
231
+ if title:
232
+ prompt_html += f"<title>{html.escape(title)}</title>\n"
233
+ if description:
234
+ prompt_html += f"<description>{html.escape(description)}</description>\n"
235
+ prompt_html += message
236
+ if warning:
237
+ prompt_html += f"\n<warning>{html.escape(warning)}</warning>"
238
+
239
+ formatted_prompt = HTML(f"\n{prompt_html}\n")
240
+
241
+ # Convert to prompt_toolkit format
242
+ choice_options_formatted = []
243
+ for opt in choice_options:
244
+ label = opt.label
245
+ # Add default marker after the label if applicable
246
+ if opt.is_default:
247
+ label = f"{label} <default>*</default>"
248
+ choice_options_formatted.append((opt.value, HTML(label) if "<" in label else label))
249
+
250
+ # Set up ESC key binding
251
+ key_bindings = KeyBindings()
252
+ if allow_esc:
253
+ default_esc_value = esc_value or (choice_options[0].value if choice_options else "")
254
+ result_on_esc = default_esc_value
255
+
256
+ @key_bindings.add("escape", eager=True)
257
+ def _esc_handler(event: Any) -> None: # noqa: ANN001 (called by key_binding)
258
+ event.app.exit(result=result_on_esc, style="class:aborting")
259
+
260
+ # Show the choice dialog
261
+ result = choice(
262
+ message=formatted_prompt,
263
+ options=choice_options_formatted,
264
+ style=style or _choice_style(),
265
+ show_frame=~is_done,
266
+ key_bindings=key_bindings if allow_esc else None,
267
+ )
268
+
269
+ # Clear the prompt from screen by calculating the exact number of lines
270
+ # ANSI codes: ESC[F = move cursor to beginning of previous line
271
+ # ESC[2K = clear entire line
272
+ #
273
+ # Calculate lines to clear:
274
+ # - Options: len(choice_options)
275
+ # NOTE: prompt_toolkit renders a final "done" state without the frame
276
+ # when show_frame=~is_done, so we should not include frame borders here.
277
+ term_width = _terminal_width()
278
+ message_width = max(1, term_width - 2) # account for label padding
279
+ lines_to_clear = _count_display_lines(formatted_prompt, message_width)
280
+ lines_to_clear += len(choice_options_formatted)
281
+
282
+ for _ in range(lines_to_clear):
283
+ print("\033[F\033[2K", end="", flush=True)
284
+
285
+ return result
286
+
287
+
288
+ def prompt_yes_no(
289
+ message: str,
290
+ *,
291
+ title: Optional[str] = None,
292
+ allow_session: bool = True,
293
+ ) -> str:
294
+ """Prompt a yes/no question with optional session remember option.
295
+
296
+ Args:
297
+ message: The question to ask
298
+ title: Optional title
299
+ allow_session: Whether to include "Yes, for this session" option
300
+
301
+ Returns:
302
+ "y" for yes, "s" for session, "n" for no
303
+
304
+ Example:
305
+ ```python
306
+ answer = prompt_yes_no("Continue with installation?")
307
+ if answer in ("y", "s"):
308
+ # User approved
309
+ ...
310
+ ```
311
+ """
312
+ options: list[tuple[str, str]] = [
313
+ ("y", "<yes-option>Yes</yes-option>"),
314
+ ]
315
+
316
+ if allow_session:
317
+ options.append(("s", "<yes-option>Yes, for this session</yes-option>"))
318
+
319
+ options.append(("n", "<no-option>No</no-option>"))
320
+
321
+ return prompt_choice(
322
+ message=f"<question>{html.escape(message)}</question>",
323
+ options=options,
324
+ title=title,
325
+ allow_esc=True,
326
+ esc_value="n", # ESC means no
327
+ )
328
+
329
+
330
+ def prompt_select(
331
+ message: str,
332
+ options: list[str],
333
+ *,
334
+ default: Optional[str] = None,
335
+ title: Optional[str] = None,
336
+ ) -> Optional[str]:
337
+ """Prompt the user to select from a list of string options.
338
+
339
+ A simplified version of prompt_choice for simple string lists.
340
+
341
+ Args:
342
+ message: The prompt message
343
+ options: List of option strings
344
+ default: The default option value
345
+ title: Optional title
346
+
347
+ Returns:
348
+ The selected option value, or None if canceled
349
+
350
+ Example:
351
+ ```python
352
+ provider = prompt_select(
353
+ "Choose your model provider",
354
+ ["openai", "anthropic", "deepseek"],
355
+ default="deepseek"
356
+ )
357
+ ```
358
+ """
359
+ choice_options = [
360
+ ChoiceOption(
361
+ opt,
362
+ f"<info>{opt}</info>",
363
+ is_default=(opt == default),
364
+ )
365
+ for opt in options
366
+ ]
367
+
368
+ result = prompt_choice(
369
+ message=message,
370
+ options=choice_options,
371
+ title=title,
372
+ allow_esc=True,
373
+ esc_value=default or (options[0] if options else None),
374
+ )
375
+
376
+ return result if result else None
@@ -0,0 +1,5 @@
1
+ """Textual-based models TUI."""
2
+
3
+ from .textual_app import run_models_tui
4
+
5
+ __all__ = ["run_models_tui"]