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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +9 -1
- ripperdoc/cli/commands/agents_cmd.py +93 -53
- ripperdoc/cli/commands/mcp_cmd.py +3 -0
- ripperdoc/cli/commands/models_cmd.py +768 -283
- ripperdoc/cli/commands/permissions_cmd.py +107 -52
- ripperdoc/cli/commands/resume_cmd.py +61 -51
- ripperdoc/cli/commands/themes_cmd.py +31 -1
- ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
- ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
- ripperdoc/cli/ui/choice.py +376 -0
- ripperdoc/cli/ui/models_tui/__init__.py +5 -0
- ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
- ripperdoc/cli/ui/panels.py +19 -4
- ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
- ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
- ripperdoc/cli/ui/provider_options.py +220 -80
- ripperdoc/cli/ui/rich_ui.py +9 -11
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +25 -70
- ripperdoc/core/providers/anthropic.py +11 -0
- ripperdoc/protocol/stdio.py +3 -1
- ripperdoc/tools/bash_tool.py +2 -0
- ripperdoc/tools/file_edit_tool.py +100 -181
- ripperdoc/tools/file_read_tool.py +101 -25
- ripperdoc/tools/multi_edit_tool.py +239 -91
- ripperdoc/tools/notebook_edit_tool.py +11 -29
- ripperdoc/utils/file_editing.py +164 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {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
|