ripperdoc 0.3.0__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/interrupt_listener.py +233 -0
- ripperdoc/cli/ui/message_display.py +7 -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 +91 -83
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +66 -104
- 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.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
- ripperdoc/cli/ui/interrupt_handler.py +0 -208
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -24,7 +24,7 @@ from prompt_toolkit.shortcuts.prompt import CompleteStyle
|
|
|
24
24
|
from prompt_toolkit.styles import Style
|
|
25
25
|
|
|
26
26
|
from ripperdoc.core.config import get_global_config, provider_protocol, model_supports_vision
|
|
27
|
-
from ripperdoc.core.default_tools import get_default_tools
|
|
27
|
+
from ripperdoc.core.default_tools import filter_tools_by_names, get_default_tools
|
|
28
28
|
from ripperdoc.core.theme import get_theme_manager
|
|
29
29
|
from ripperdoc.core.query import query, QueryContext
|
|
30
30
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
@@ -47,7 +47,7 @@ from ripperdoc.cli.ui.thinking_spinner import ThinkingSpinner
|
|
|
47
47
|
from ripperdoc.cli.ui.context_display import context_usage_lines
|
|
48
48
|
from ripperdoc.cli.ui.panels import create_welcome_panel, create_status_bar, print_shortcuts
|
|
49
49
|
from ripperdoc.cli.ui.message_display import MessageDisplay, parse_bash_output_sections
|
|
50
|
-
from ripperdoc.cli.ui.
|
|
50
|
+
from ripperdoc.cli.ui.interrupt_listener import EscInterruptListener
|
|
51
51
|
from ripperdoc.utils.conversation_compaction import (
|
|
52
52
|
compact_conversation,
|
|
53
53
|
CompactionResult,
|
|
@@ -77,11 +77,14 @@ from ripperdoc.utils.messages import (
|
|
|
77
77
|
UserMessage,
|
|
78
78
|
AssistantMessage,
|
|
79
79
|
ProgressMessage,
|
|
80
|
+
INTERRUPT_MESSAGE,
|
|
81
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
80
82
|
create_user_message,
|
|
81
83
|
)
|
|
82
84
|
from ripperdoc.utils.log import enable_session_file_logging, get_logger
|
|
83
85
|
from ripperdoc.utils.path_ignore import build_ignore_filter
|
|
84
86
|
from ripperdoc.cli.ui.file_mention_completer import FileMentionCompleter
|
|
87
|
+
from ripperdoc.cli.ui.tips import get_random_tip
|
|
85
88
|
from ripperdoc.utils.message_formatting import stringify_message_content
|
|
86
89
|
from ripperdoc.utils.image_utils import read_image_as_base64, is_image_file
|
|
87
90
|
|
|
@@ -349,6 +352,10 @@ class RichUI:
|
|
|
349
352
|
self._exit_reason: Optional[str] = None
|
|
350
353
|
self._using_tty_input = False # Track if we're using /dev/tty for input
|
|
351
354
|
self._thinking_mode_enabled = False # Toggle for extended thinking mode
|
|
355
|
+
self._interrupt_listener = EscInterruptListener(self._schedule_esc_interrupt, logger=logger)
|
|
356
|
+
self._esc_interrupt_seen = False
|
|
357
|
+
self._query_in_progress = False
|
|
358
|
+
self._active_spinner: Optional[ThinkingSpinner] = None
|
|
352
359
|
hook_manager.set_transcript_path(str(self._session_history.path))
|
|
353
360
|
|
|
354
361
|
# Create permission checker with Rich console and PromptSession support
|
|
@@ -398,8 +405,6 @@ class RichUI:
|
|
|
398
405
|
|
|
399
406
|
# Initialize component handlers
|
|
400
407
|
self._message_display = MessageDisplay(self.console, self.verbose, self.show_full_thinking)
|
|
401
|
-
self._interrupt_handler = InterruptHandler()
|
|
402
|
-
self._interrupt_handler.set_abort_callback(self._trigger_abort)
|
|
403
408
|
|
|
404
409
|
# Keep MCP runtime alive for the whole UI session. Create it on the UI loop up front.
|
|
405
410
|
try:
|
|
@@ -440,18 +445,6 @@ class RichUI:
|
|
|
440
445
|
# Properties for backward compatibility with interrupt handler
|
|
441
446
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
442
447
|
|
|
443
|
-
@property
|
|
444
|
-
def _query_interrupted(self) -> bool:
|
|
445
|
-
return self._interrupt_handler.was_interrupted
|
|
446
|
-
|
|
447
|
-
@property
|
|
448
|
-
def _esc_listener_paused(self) -> bool:
|
|
449
|
-
return self._interrupt_handler._esc_listener_paused
|
|
450
|
-
|
|
451
|
-
@_esc_listener_paused.setter
|
|
452
|
-
def _esc_listener_paused(self, value: bool) -> None:
|
|
453
|
-
self._interrupt_handler._esc_listener_paused = value
|
|
454
|
-
|
|
455
448
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
456
449
|
# Thinking mode toggle
|
|
457
450
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -714,9 +707,10 @@ class RichUI:
|
|
|
714
707
|
dynamic_tools = await load_dynamic_mcp_tools_async(self.project_path)
|
|
715
708
|
|
|
716
709
|
if dynamic_tools and self.query_context:
|
|
717
|
-
self.query_context.tools
|
|
718
|
-
|
|
719
|
-
|
|
710
|
+
merged_tools = merge_tools_with_dynamic(self.query_context.tools, dynamic_tools)
|
|
711
|
+
if self.allowed_tools is not None:
|
|
712
|
+
merged_tools = filter_tools_by_names(merged_tools, self.allowed_tools)
|
|
713
|
+
self.query_context.tools = merged_tools
|
|
720
714
|
|
|
721
715
|
logger.debug(
|
|
722
716
|
"[ui] Prepared tools and MCP servers",
|
|
@@ -924,6 +918,11 @@ class RichUI:
|
|
|
924
918
|
last_tool_name: Optional[str] = None
|
|
925
919
|
|
|
926
920
|
if isinstance(message.message.content, str):
|
|
921
|
+
if self._esc_interrupt_seen and message.message.content.strip() in (
|
|
922
|
+
INTERRUPT_MESSAGE,
|
|
923
|
+
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
|
924
|
+
):
|
|
925
|
+
return last_tool_name
|
|
927
926
|
with pause():
|
|
928
927
|
self.display_message("Ripperdoc", message.message.content)
|
|
929
928
|
elif isinstance(message.message.content, list):
|
|
@@ -1156,11 +1155,26 @@ class RichUI:
|
|
|
1156
1155
|
spinner = ThinkingSpinner(console, prompt_tokens_est)
|
|
1157
1156
|
|
|
1158
1157
|
def pause_ui() -> None:
|
|
1159
|
-
|
|
1158
|
+
self._pause_interrupt_listener()
|
|
1159
|
+
try:
|
|
1160
|
+
spinner.stop()
|
|
1161
|
+
except (RuntimeError, ValueError, OSError):
|
|
1162
|
+
logger.debug("[ui] Failed to pause spinner")
|
|
1160
1163
|
|
|
1161
1164
|
def resume_ui() -> None:
|
|
1162
|
-
|
|
1163
|
-
|
|
1165
|
+
if self._esc_interrupt_seen:
|
|
1166
|
+
return
|
|
1167
|
+
try:
|
|
1168
|
+
spinner.start()
|
|
1169
|
+
spinner.update("Thinking...")
|
|
1170
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
1171
|
+
logger.debug(
|
|
1172
|
+
"[ui] Failed to restart spinner after pause: %s: %s",
|
|
1173
|
+
type(exc).__name__,
|
|
1174
|
+
exc,
|
|
1175
|
+
)
|
|
1176
|
+
finally:
|
|
1177
|
+
self._resume_interrupt_listener()
|
|
1164
1178
|
|
|
1165
1179
|
self.query_context.pause_ui = pause_ui
|
|
1166
1180
|
self.query_context.resume_ui = resume_ui
|
|
@@ -1169,8 +1183,7 @@ class RichUI:
|
|
|
1169
1183
|
base_permission_checker = self._permission_checker
|
|
1170
1184
|
|
|
1171
1185
|
async def permission_checker(tool: Any, parsed_input: Any) -> bool:
|
|
1172
|
-
|
|
1173
|
-
was_paused = self._pause_interrupt_listener()
|
|
1186
|
+
pause_ui()
|
|
1174
1187
|
try:
|
|
1175
1188
|
if base_permission_checker is not None:
|
|
1176
1189
|
result = await base_permission_checker(tool, parsed_input)
|
|
@@ -1186,18 +1199,7 @@ class RichUI:
|
|
|
1186
1199
|
return allowed
|
|
1187
1200
|
return True
|
|
1188
1201
|
finally:
|
|
1189
|
-
|
|
1190
|
-
# Wrap spinner restart in try-except to prevent exceptions
|
|
1191
|
-
# from discarding the permission result
|
|
1192
|
-
try:
|
|
1193
|
-
spinner.start()
|
|
1194
|
-
spinner.update("Thinking...")
|
|
1195
|
-
except (RuntimeError, ValueError, OSError) as exc:
|
|
1196
|
-
logger.debug(
|
|
1197
|
-
"[ui] Failed to restart spinner after permission check: %s: %s",
|
|
1198
|
-
type(exc).__name__,
|
|
1199
|
-
exc,
|
|
1200
|
-
)
|
|
1202
|
+
resume_ui()
|
|
1201
1203
|
|
|
1202
1204
|
# Process query stream
|
|
1203
1205
|
tool_registry: Dict[str, Dict[str, Any]] = {}
|
|
@@ -1205,6 +1207,10 @@ class RichUI:
|
|
|
1205
1207
|
output_token_est = 0
|
|
1206
1208
|
|
|
1207
1209
|
try:
|
|
1210
|
+
self._active_spinner = spinner
|
|
1211
|
+
self._esc_interrupt_seen = False
|
|
1212
|
+
self._query_in_progress = True
|
|
1213
|
+
self._start_interrupt_listener()
|
|
1208
1214
|
spinner.start()
|
|
1209
1215
|
async for message in query(
|
|
1210
1216
|
messages,
|
|
@@ -1253,6 +1259,9 @@ class RichUI:
|
|
|
1253
1259
|
extra={"session_id": self.session_id},
|
|
1254
1260
|
)
|
|
1255
1261
|
|
|
1262
|
+
self._stop_interrupt_listener()
|
|
1263
|
+
self._query_in_progress = False
|
|
1264
|
+
self._active_spinner = None
|
|
1256
1265
|
self.conversation_messages = messages
|
|
1257
1266
|
logger.info(
|
|
1258
1267
|
"[ui] Query processing completed",
|
|
@@ -1279,21 +1288,49 @@ class RichUI:
|
|
|
1279
1288
|
# ESC Key Interrupt Support
|
|
1280
1289
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
1281
1290
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1291
|
+
def _schedule_esc_interrupt(self) -> None:
|
|
1292
|
+
"""Schedule ESC interrupt handling on the UI event loop."""
|
|
1293
|
+
if self._loop.is_closed():
|
|
1294
|
+
return
|
|
1295
|
+
try:
|
|
1296
|
+
self._loop.call_soon_threadsafe(self._handle_esc_interrupt)
|
|
1297
|
+
except RuntimeError:
|
|
1298
|
+
pass
|
|
1299
|
+
|
|
1300
|
+
def _handle_esc_interrupt(self) -> None:
|
|
1301
|
+
"""Abort the current query and display the interrupt notice."""
|
|
1302
|
+
if not self._query_in_progress:
|
|
1303
|
+
return
|
|
1304
|
+
if self._esc_interrupt_seen:
|
|
1305
|
+
return
|
|
1306
|
+
abort_controller = getattr(self.query_context, "abort_controller", None)
|
|
1307
|
+
if abort_controller is None or abort_controller.is_set():
|
|
1308
|
+
return
|
|
1285
1309
|
|
|
1286
|
-
|
|
1287
|
-
self._interrupt_handler.resume_listener(previous_state)
|
|
1310
|
+
self._esc_interrupt_seen = True
|
|
1288
1311
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
self.
|
|
1312
|
+
try:
|
|
1313
|
+
if self.query_context and self.query_context.pause_ui:
|
|
1314
|
+
self.query_context.pause_ui()
|
|
1315
|
+
elif self._active_spinner:
|
|
1316
|
+
self._active_spinner.stop()
|
|
1317
|
+
except (RuntimeError, ValueError, OSError):
|
|
1318
|
+
logger.debug("[ui] Failed to pause spinner for ESC interrupt")
|
|
1293
1319
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1320
|
+
self._message_display.print_interrupt_notice()
|
|
1321
|
+
abort_controller.set()
|
|
1322
|
+
|
|
1323
|
+
def _start_interrupt_listener(self) -> None:
|
|
1324
|
+
self._interrupt_listener.start()
|
|
1325
|
+
|
|
1326
|
+
def _stop_interrupt_listener(self) -> None:
|
|
1327
|
+
self._interrupt_listener.stop()
|
|
1328
|
+
|
|
1329
|
+
def _pause_interrupt_listener(self) -> None:
|
|
1330
|
+
self._interrupt_listener.pause()
|
|
1331
|
+
|
|
1332
|
+
def _resume_interrupt_listener(self) -> None:
|
|
1333
|
+
self._interrupt_listener.resume()
|
|
1297
1334
|
|
|
1298
1335
|
def _run_async(self, coro: Any) -> Any:
|
|
1299
1336
|
"""Run a coroutine on the persistent event loop."""
|
|
@@ -1302,16 +1339,6 @@ class RichUI:
|
|
|
1302
1339
|
asyncio.set_event_loop(self._loop)
|
|
1303
1340
|
return self._loop.run_until_complete(coro)
|
|
1304
1341
|
|
|
1305
|
-
def _run_async_with_esc_interrupt(self, coro: Any) -> bool:
|
|
1306
|
-
"""Run a coroutine with ESC key interrupt support.
|
|
1307
|
-
|
|
1308
|
-
Returns True if interrupted by ESC, False if completed normally.
|
|
1309
|
-
"""
|
|
1310
|
-
if self._loop.is_closed():
|
|
1311
|
-
self._loop = asyncio.new_event_loop()
|
|
1312
|
-
asyncio.set_event_loop(self._loop)
|
|
1313
|
-
return self._loop.run_until_complete(self._run_query_with_esc_interrupt(coro))
|
|
1314
|
-
|
|
1315
1342
|
def run_async(self, coro: Any) -> Any:
|
|
1316
1343
|
"""Public wrapper for running coroutines on the UI event loop."""
|
|
1317
1344
|
return self._run_async(coro)
|
|
@@ -1532,14 +1559,9 @@ class RichUI:
|
|
|
1532
1559
|
console.print(create_welcome_panel())
|
|
1533
1560
|
console.print()
|
|
1534
1561
|
|
|
1535
|
-
# Display
|
|
1536
|
-
|
|
1537
|
-
console.print()
|
|
1538
|
-
console.print(
|
|
1539
|
-
"[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. "
|
|
1540
|
-
"Press Alt+Enter for newline. Press Tab to toggle thinking mode. "
|
|
1541
|
-
"Press ESC to interrupt.[/dim]\n"
|
|
1542
|
-
)
|
|
1562
|
+
# Display random tip
|
|
1563
|
+
random_tip = get_random_tip()
|
|
1564
|
+
console.print(f"[dim italic]💡 {random_tip}[/dim italic]\n")
|
|
1543
1565
|
|
|
1544
1566
|
session = self.get_prompt_session()
|
|
1545
1567
|
logger.info(
|
|
@@ -1562,8 +1584,7 @@ class RichUI:
|
|
|
1562
1584
|
)
|
|
1563
1585
|
console.print() # Add spacing before response
|
|
1564
1586
|
|
|
1565
|
-
#
|
|
1566
|
-
# since there's no TTY for ESC key detection
|
|
1587
|
+
# Process initial query (ESC interrupt handling removed)
|
|
1567
1588
|
self._run_async(self.process_query(self._initial_query))
|
|
1568
1589
|
|
|
1569
1590
|
logger.info(
|
|
@@ -1614,21 +1635,8 @@ class RichUI:
|
|
|
1614
1635
|
},
|
|
1615
1636
|
)
|
|
1616
1637
|
|
|
1617
|
-
#
|
|
1618
|
-
|
|
1619
|
-
self._run_async(self.process_query(user_input))
|
|
1620
|
-
else:
|
|
1621
|
-
interrupted = self._run_async_with_esc_interrupt(
|
|
1622
|
-
self.process_query(user_input)
|
|
1623
|
-
)
|
|
1624
|
-
if interrupted:
|
|
1625
|
-
console.print(
|
|
1626
|
-
"\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
|
|
1627
|
-
)
|
|
1628
|
-
logger.info(
|
|
1629
|
-
"[ui] Query interrupted by ESC key",
|
|
1630
|
-
extra={"session_id": self.session_id},
|
|
1631
|
-
)
|
|
1638
|
+
# Run query (ESC interrupt handling removed)
|
|
1639
|
+
self._run_async(self.process_query(user_input))
|
|
1632
1640
|
|
|
1633
1641
|
console.print() # Add spacing between interactions
|
|
1634
1642
|
|
ripperdoc/cli/ui/tips.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Random tips for Ripperdoc CLI.
|
|
2
|
+
|
|
3
|
+
This module provides a collection of tips and tricks for using Ripperdoc,
|
|
4
|
+
displayed randomly at startup to help users discover features.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
# Tips database - organized by category
|
|
11
|
+
TIPS: List[str] = [
|
|
12
|
+
# Navigation & Input
|
|
13
|
+
"Press Tab to toggle thinking mode - see the AI's full reasoning process.",
|
|
14
|
+
"Use @ followed by a file path to reference files in your conversation.",
|
|
15
|
+
"Press Alt+Enter to insert newlines in your input for multi-line prompts.",
|
|
16
|
+
"Type / then press Tab to see all available slash commands.",
|
|
17
|
+
"Double-press Ctrl+C to quickly exit Ripperdoc.",
|
|
18
|
+
|
|
19
|
+
# Slash Commands
|
|
20
|
+
"Use /help to see all available commands and get started.",
|
|
21
|
+
"Use /clear to clear the current conversation and start fresh.",
|
|
22
|
+
"Use /compact to compress conversation history when tokens run low.",
|
|
23
|
+
"Use /status to check your session statistics and context usage.",
|
|
24
|
+
"Use /cost to estimate token costs for your current session.",
|
|
25
|
+
"Use /theme to switch between different visual themes (light, dark, etc.).",
|
|
26
|
+
"Use /models to list and switch between different AI models.",
|
|
27
|
+
"Use /tools to see what tools are available to the AI agent.",
|
|
28
|
+
"Use /agents to manage specialized AI agents for specific tasks.",
|
|
29
|
+
"Use /config to view or modify your Ripperdoc configuration.",
|
|
30
|
+
"Use /todos to track tasks and manage your todo list.",
|
|
31
|
+
|
|
32
|
+
# Advanced Features
|
|
33
|
+
"Use /plan to enter planning mode for complex tasks requiring exploration.",
|
|
34
|
+
"Use /memory to enable persistent memory across sessions.",
|
|
35
|
+
"Use /skills to load and manage custom skill packages.",
|
|
36
|
+
"Use /mcp to configure Model Context Protocol servers for extended capabilities.",
|
|
37
|
+
"Use /permissions to adjust tool permission settings.",
|
|
38
|
+
"Use /context to manage context window and conversation history.",
|
|
39
|
+
"Use /resume to continue your most recent conversation.",
|
|
40
|
+
|
|
41
|
+
# Productivity
|
|
42
|
+
"Use /exit to cleanly shut down Ripperdoc and save your session.",
|
|
43
|
+
"Use /doctor to diagnose common issues and configuration problems.",
|
|
44
|
+
"Use /hooks to manage custom hooks for session lifecycle events.",
|
|
45
|
+
|
|
46
|
+
# File Operations
|
|
47
|
+
"Reference images with @path/to/image.png if your model supports vision.",
|
|
48
|
+
"Use /tasks to view and manage background tasks.",
|
|
49
|
+
|
|
50
|
+
# Hidden/Power User Features
|
|
51
|
+
"Create custom commands in .ripperdoc/commands/ for reusable workflows.",
|
|
52
|
+
"Configure hooks in .ripperdoc/hooks/ to automate session events.",
|
|
53
|
+
"Use --yolo mode to skip all permission prompts (use with caution!).",
|
|
54
|
+
"Pipe stdin into Ripperdoc: echo 'your query' | ripperdoc",
|
|
55
|
+
"Use -p flag for single-shot queries: ripperdoc -p 'your prompt'",
|
|
56
|
+
|
|
57
|
+
# MCP & Extensions
|
|
58
|
+
"Connect to MCP servers to extend Ripperdoc with external tools and data sources.",
|
|
59
|
+
"Use /skills to enable specialized capabilities like PDF or spreadsheet processing.",
|
|
60
|
+
|
|
61
|
+
# Session Management
|
|
62
|
+
"Sessions are automatically saved in .ripperdoc/sessions/.",
|
|
63
|
+
"Use /continue or -c flag to resume your last conversation.",
|
|
64
|
+
|
|
65
|
+
# Cost & Performance
|
|
66
|
+
"Enable auto-compact in settings to automatically manage token usage.",
|
|
67
|
+
"Use /cost to monitor token consumption and estimate API costs.",
|
|
68
|
+
|
|
69
|
+
# Tips about Tips
|
|
70
|
+
"A new tip appears each time you start Ripperdoc - try restarting to see more!",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_random_tip() -> str:
|
|
75
|
+
"""Get a random tip from the tips database.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A randomly selected tip string.
|
|
79
|
+
"""
|
|
80
|
+
return random.choice(TIPS)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_tips_count() -> int:
|
|
84
|
+
"""Get the total number of tips available.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The count of tips in the database.
|
|
88
|
+
"""
|
|
89
|
+
return len(TIPS)
|
ripperdoc/cli/ui/wizard.py
CHANGED
|
@@ -8,6 +8,7 @@ from typing import List, Optional, Tuple
|
|
|
8
8
|
import click
|
|
9
9
|
from rich.console import Console
|
|
10
10
|
|
|
11
|
+
from ripperdoc.cli.ui.choice import ChoiceOption, onboarding_style, prompt_choice
|
|
11
12
|
from ripperdoc.cli.ui.provider_options import (
|
|
12
13
|
KNOWN_PROVIDERS,
|
|
13
14
|
ProviderOption,
|
|
@@ -66,38 +67,64 @@ def check_onboarding() -> bool:
|
|
|
66
67
|
|
|
67
68
|
def run_onboarding_wizard(config: GlobalConfig) -> bool:
|
|
68
69
|
"""Run interactive onboarding wizard."""
|
|
69
|
-
provider_keys = KNOWN_PROVIDERS.keys() + ["custom"]
|
|
70
|
+
provider_keys = list(KNOWN_PROVIDERS.keys()) + ["custom"]
|
|
70
71
|
default_choice_key = KNOWN_PROVIDERS.default_choice.key
|
|
71
72
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# Prompt for provider choice with validation
|
|
80
|
-
provider_choice: Optional[str] = None
|
|
81
|
-
while provider_choice is None:
|
|
82
|
-
raw_choice = click.prompt(
|
|
83
|
-
"Choose your model provider",
|
|
84
|
-
default=default_choice_key,
|
|
73
|
+
# Build provider choice options with rich styling
|
|
74
|
+
provider_options = [
|
|
75
|
+
ChoiceOption(
|
|
76
|
+
key,
|
|
77
|
+
key, # Plain text, not colored
|
|
78
|
+
is_default=(key == default_choice_key),
|
|
85
79
|
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
for key in provider_keys
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# Prompt for provider choice using unified choice component
|
|
84
|
+
provider_choice = prompt_choice(
|
|
85
|
+
message="Choose your model provider",
|
|
86
|
+
options=provider_options,
|
|
87
|
+
title="Available providers",
|
|
88
|
+
allow_esc=True,
|
|
89
|
+
esc_value=default_choice_key,
|
|
90
|
+
style=onboarding_style(),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Validate the choice (in case user typed an invalid value)
|
|
94
|
+
validated_choice = resolve_provider_choice(provider_choice, provider_keys)
|
|
95
|
+
if validated_choice is None:
|
|
96
|
+
console.print(
|
|
97
|
+
f"[red]Invalid choice. Please enter a provider name or number (1-{len(provider_keys)}).[/red]"
|
|
98
|
+
)
|
|
99
|
+
return run_onboarding_wizard(config)
|
|
100
|
+
provider_choice = validated_choice
|
|
91
101
|
|
|
92
102
|
api_base_override: Optional[str] = None
|
|
93
103
|
if provider_choice == "custom":
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
# Build protocol choice options
|
|
105
|
+
protocol_options = [
|
|
106
|
+
ChoiceOption(
|
|
107
|
+
p.value,
|
|
108
|
+
p.value, # Plain text
|
|
109
|
+
is_default=(p == ProviderType.OPENAI_COMPATIBLE),
|
|
110
|
+
)
|
|
111
|
+
for p in ProviderType
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
protocol_input = prompt_choice(
|
|
115
|
+
message="Choose the protocol family (for API compatibility)",
|
|
116
|
+
options=protocol_options,
|
|
117
|
+
title="Custom Provider - Protocol Selection",
|
|
118
|
+
allow_esc=True,
|
|
119
|
+
esc_value=ProviderType.OPENAI_COMPATIBLE.value,
|
|
120
|
+
style=onboarding_style(),
|
|
98
121
|
)
|
|
99
122
|
protocol = ProviderType(protocol_input)
|
|
100
|
-
|
|
123
|
+
|
|
124
|
+
# Get API base URL - use click.prompt for free-form text input
|
|
125
|
+
console.print("\n[dim]Now we need your API Base URL.[/dim]")
|
|
126
|
+
api_base_override = click.prompt("API Base URL").strip()
|
|
127
|
+
|
|
101
128
|
provider_option = ProviderOption(
|
|
102
129
|
key="custom",
|
|
103
130
|
protocol=protocol,
|
|
@@ -160,29 +187,55 @@ def get_model_name_with_suggestions(
|
|
|
160
187
|
default_model = provider.default_model or default_model_for_protocol(provider.protocol)
|
|
161
188
|
suggestions = list(provider.model_suggestions)
|
|
162
189
|
|
|
163
|
-
#
|
|
190
|
+
# Prompt for model name using unified choice component if suggestions exist
|
|
164
191
|
if suggestions:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
# Create choice options for models
|
|
193
|
+
model_options = [
|
|
194
|
+
ChoiceOption(model, model) # Plain text
|
|
195
|
+
for model in suggestions[:5] # Show top 5
|
|
196
|
+
]
|
|
197
|
+
# Add custom option
|
|
198
|
+
model_options.append(ChoiceOption("custom", "<dim>Custom model...</dim>"))
|
|
199
|
+
|
|
200
|
+
model = prompt_choice(
|
|
201
|
+
message="Select a model or choose 'Custom' to enter manually",
|
|
202
|
+
options=model_options,
|
|
203
|
+
title="Available models for this provider",
|
|
204
|
+
description=f"Default: {default_model}",
|
|
205
|
+
allow_esc=True,
|
|
206
|
+
esc_value=default_model,
|
|
207
|
+
style=onboarding_style(),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# If user chose custom, prompt for manual input using click.prompt
|
|
211
|
+
if model == "custom":
|
|
212
|
+
console.print("\n[dim]Enter your custom model name:[/dim]")
|
|
213
|
+
model = click.prompt("Model name", default=default_model).strip()
|
|
184
214
|
else:
|
|
185
|
-
|
|
215
|
+
# No suggestions, use default
|
|
216
|
+
model = default_model
|
|
217
|
+
|
|
218
|
+
# Prompt for API base if still not set (for OpenAI-compatible providers)
|
|
219
|
+
if provider.protocol == ProviderType.OPENAI_COMPATIBLE and api_base is None:
|
|
220
|
+
api_base_input = prompt_choice(
|
|
221
|
+
message="Enter API base URL (or press Enter to skip)",
|
|
222
|
+
options=[ChoiceOption("", "<dim>Skip</dim>")],
|
|
223
|
+
title="API base URL (optional)",
|
|
224
|
+
allow_esc=True,
|
|
225
|
+
esc_value="",
|
|
226
|
+
style=onboarding_style(),
|
|
227
|
+
)
|
|
228
|
+
api_base = api_base_input or None
|
|
229
|
+
elif provider.protocol == ProviderType.GEMINI and api_base is None:
|
|
230
|
+
api_base_input = prompt_choice(
|
|
231
|
+
message="Enter API base URL (or press Enter to skip)",
|
|
232
|
+
options=[ChoiceOption("", "<dim>Skip</dim>")],
|
|
233
|
+
title="API base URL (optional)",
|
|
234
|
+
allow_esc=True,
|
|
235
|
+
esc_value="",
|
|
236
|
+
style=onboarding_style(),
|
|
237
|
+
)
|
|
238
|
+
api_base = api_base_input or None
|
|
186
239
|
|
|
187
240
|
return model, api_base
|
|
188
241
|
|
ripperdoc/core/config.py
CHANGED
|
@@ -230,6 +230,7 @@ class GlobalConfig(BaseModel):
|
|
|
230
230
|
# User-level permission rules (applied globally)
|
|
231
231
|
user_allow_rules: list[str] = Field(default_factory=list)
|
|
232
232
|
user_deny_rules: list[str] = Field(default_factory=list)
|
|
233
|
+
user_ask_rules: list[str] = Field(default_factory=list)
|
|
233
234
|
|
|
234
235
|
# Onboarding
|
|
235
236
|
has_completed_onboarding: bool = False
|
|
@@ -258,6 +259,7 @@ class ProjectConfig(BaseModel):
|
|
|
258
259
|
allowed_tools: list[str] = Field(default_factory=list)
|
|
259
260
|
bash_allow_rules: list[str] = Field(default_factory=list)
|
|
260
261
|
bash_deny_rules: list[str] = Field(default_factory=list)
|
|
262
|
+
bash_ask_rules: list[str] = Field(default_factory=list)
|
|
261
263
|
working_directories: list[str] = Field(default_factory=list)
|
|
262
264
|
|
|
263
265
|
# Path ignore patterns (gitignore-style)
|
|
@@ -291,6 +293,7 @@ class ProjectLocalConfig(BaseModel):
|
|
|
291
293
|
# Local permission rules (project-specific but not shared)
|
|
292
294
|
local_allow_rules: list[str] = Field(default_factory=list)
|
|
293
295
|
local_deny_rules: list[str] = Field(default_factory=list)
|
|
296
|
+
local_ask_rules: list[str] = Field(default_factory=list)
|
|
294
297
|
|
|
295
298
|
|
|
296
299
|
class ConfigManager:
|