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.
Files changed (40) 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/interrupt_listener.py +233 -0
  13. ripperdoc/cli/ui/message_display.py +7 -0
  14. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  15. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  16. ripperdoc/cli/ui/panels.py +19 -4
  17. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  18. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  19. ripperdoc/cli/ui/provider_options.py +220 -80
  20. ripperdoc/cli/ui/rich_ui.py +91 -83
  21. ripperdoc/cli/ui/tips.py +89 -0
  22. ripperdoc/cli/ui/wizard.py +98 -45
  23. ripperdoc/core/config.py +3 -0
  24. ripperdoc/core/permissions.py +66 -104
  25. ripperdoc/core/providers/anthropic.py +11 -0
  26. ripperdoc/protocol/stdio.py +3 -1
  27. ripperdoc/tools/bash_tool.py +2 -0
  28. ripperdoc/tools/file_edit_tool.py +100 -181
  29. ripperdoc/tools/file_read_tool.py +101 -25
  30. ripperdoc/tools/multi_edit_tool.py +239 -91
  31. ripperdoc/tools/notebook_edit_tool.py +11 -29
  32. ripperdoc/utils/file_editing.py +164 -0
  33. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  34. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  35. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
  36. ripperdoc/cli/ui/interrupt_handler.py +0 -208
  37. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  38. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  39. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  40. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
@@ -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.interrupt_handler import InterruptHandler
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 = merge_tools_with_dynamic(
718
- self.query_context.tools, dynamic_tools
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
- spinner.stop()
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
- spinner.start()
1163
- spinner.update("Thinking...")
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
- spinner.stop()
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
- self._resume_interrupt_listener(was_paused)
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
- # Delegate to InterruptHandler
1283
- def _pause_interrupt_listener(self) -> bool:
1284
- return self._interrupt_handler.pause_listener()
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
- def _resume_interrupt_listener(self, previous_state: bool) -> None:
1287
- self._interrupt_handler.resume_listener(previous_state)
1310
+ self._esc_interrupt_seen = True
1288
1311
 
1289
- def _trigger_abort(self) -> None:
1290
- """Signal the query to abort."""
1291
- if self.query_context and hasattr(self.query_context, "abort_controller"):
1292
- self.query_context.abort_controller.set()
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
- async def _run_query_with_esc_interrupt(self, query_coro: Any) -> bool:
1295
- """Run a query with ESC key interrupt support."""
1296
- return await self._interrupt_handler.run_with_interrupt(query_coro)
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 status
1536
- console.print(create_status_bar())
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
- # Use _run_async instead of _run_async_with_esc_interrupt for piped stdin
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
- # When using /dev/tty input, disable ESC interrupt to avoid conflicts
1618
- if self._using_tty_input:
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
 
@@ -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)
@@ -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
- # Display provider options vertically
73
- console.print("[bold]Available providers:[/bold]")
74
- for i, provider_key in enumerate(provider_keys, 1):
75
- marker = "[cyan]→[/cyan]" if provider_key == default_choice_key else " "
76
- console.print(f" {marker} {i}. {provider_key}")
77
- console.print("")
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
- provider_choice = resolve_provider_choice(raw_choice, provider_keys)
87
- if provider_choice is None:
88
- console.print(
89
- f"[red]Invalid choice. Please enter a provider name or number (1-{len(provider_keys)}).[/red]"
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
- protocol_input = click.prompt(
95
- "Protocol family (for API compatibility)",
96
- type=click.Choice([p.value for p in ProviderType]),
97
- default=ProviderType.OPENAI_COMPATIBLE.value,
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
- api_base_override = click.prompt("API Base URL")
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
- # Show suggestions if available
190
+ # Prompt for model name using unified choice component if suggestions exist
164
191
  if suggestions:
165
- console.print("\n[dim]Available models for this provider:[/dim]")
166
- for i, model_name in enumerate(suggestions[:5]): # Show top 5
167
- console.print(f" [dim]{i + 1}. {model_name}[/dim]")
168
- console.print("")
169
-
170
- # Prompt for model name
171
- if provider.protocol == ProviderType.ANTHROPIC:
172
- model = click.prompt("Model name", default=default_model)
173
- elif provider.protocol == ProviderType.OPENAI_COMPATIBLE:
174
- model = click.prompt("Model name", default=default_model)
175
- # Prompt for API base if still not set
176
- if api_base is None:
177
- api_base_input = click.prompt("API base URL (optional)", default="", show_default=False)
178
- api_base = api_base_input or None
179
- elif provider.protocol == ProviderType.GEMINI:
180
- model = click.prompt("Model name", default=default_model)
181
- if api_base is None:
182
- api_base_input = click.prompt("API base URL (optional)", default="", show_default=False)
183
- api_base = api_base_input or None
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
- model = click.prompt("Model name", default=default_model)
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: