ripperdoc 0.2.7__py3-none-any.whl → 0.2.9__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 (87) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +33 -115
  3. ripperdoc/cli/commands/__init__.py +70 -6
  4. ripperdoc/cli/commands/agents_cmd.py +6 -3
  5. ripperdoc/cli/commands/clear_cmd.py +1 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/help_cmd.py +11 -1
  10. ripperdoc/cli/commands/hooks_cmd.py +610 -0
  11. ripperdoc/cli/commands/models_cmd.py +26 -9
  12. ripperdoc/cli/commands/permissions_cmd.py +57 -37
  13. ripperdoc/cli/commands/resume_cmd.py +6 -4
  14. ripperdoc/cli/commands/status_cmd.py +4 -4
  15. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  16. ripperdoc/cli/ui/file_mention_completer.py +64 -8
  17. ripperdoc/cli/ui/interrupt_handler.py +3 -4
  18. ripperdoc/cli/ui/message_display.py +5 -3
  19. ripperdoc/cli/ui/panels.py +13 -10
  20. ripperdoc/cli/ui/provider_options.py +247 -0
  21. ripperdoc/cli/ui/rich_ui.py +196 -77
  22. ripperdoc/cli/ui/spinner.py +25 -1
  23. ripperdoc/cli/ui/tool_renderers.py +8 -2
  24. ripperdoc/cli/ui/wizard.py +215 -0
  25. ripperdoc/core/agents.py +9 -3
  26. ripperdoc/core/config.py +49 -12
  27. ripperdoc/core/custom_commands.py +412 -0
  28. ripperdoc/core/default_tools.py +11 -2
  29. ripperdoc/core/hooks/__init__.py +99 -0
  30. ripperdoc/core/hooks/config.py +301 -0
  31. ripperdoc/core/hooks/events.py +535 -0
  32. ripperdoc/core/hooks/executor.py +496 -0
  33. ripperdoc/core/hooks/integration.py +344 -0
  34. ripperdoc/core/hooks/manager.py +745 -0
  35. ripperdoc/core/permissions.py +40 -8
  36. ripperdoc/core/providers/anthropic.py +548 -68
  37. ripperdoc/core/providers/gemini.py +70 -5
  38. ripperdoc/core/providers/openai.py +60 -5
  39. ripperdoc/core/query.py +140 -39
  40. ripperdoc/core/query_utils.py +2 -0
  41. ripperdoc/core/skills.py +9 -3
  42. ripperdoc/core/system_prompt.py +4 -2
  43. ripperdoc/core/tool.py +9 -5
  44. ripperdoc/sdk/client.py +2 -2
  45. ripperdoc/tools/ask_user_question_tool.py +5 -3
  46. ripperdoc/tools/background_shell.py +2 -1
  47. ripperdoc/tools/bash_output_tool.py +1 -1
  48. ripperdoc/tools/bash_tool.py +30 -20
  49. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  50. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  51. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  52. ripperdoc/tools/file_edit_tool.py +8 -4
  53. ripperdoc/tools/file_read_tool.py +9 -5
  54. ripperdoc/tools/file_write_tool.py +9 -5
  55. ripperdoc/tools/glob_tool.py +3 -2
  56. ripperdoc/tools/grep_tool.py +3 -2
  57. ripperdoc/tools/kill_bash_tool.py +1 -1
  58. ripperdoc/tools/ls_tool.py +1 -1
  59. ripperdoc/tools/mcp_tools.py +13 -10
  60. ripperdoc/tools/multi_edit_tool.py +8 -7
  61. ripperdoc/tools/notebook_edit_tool.py +7 -4
  62. ripperdoc/tools/skill_tool.py +1 -1
  63. ripperdoc/tools/task_tool.py +5 -4
  64. ripperdoc/tools/todo_tool.py +2 -2
  65. ripperdoc/tools/tool_search_tool.py +3 -2
  66. ripperdoc/utils/conversation_compaction.py +11 -7
  67. ripperdoc/utils/file_watch.py +8 -2
  68. ripperdoc/utils/json_utils.py +2 -1
  69. ripperdoc/utils/mcp.py +11 -3
  70. ripperdoc/utils/memory.py +4 -2
  71. ripperdoc/utils/message_compaction.py +21 -7
  72. ripperdoc/utils/message_formatting.py +11 -7
  73. ripperdoc/utils/messages.py +105 -66
  74. ripperdoc/utils/path_ignore.py +38 -12
  75. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  76. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  77. ripperdoc/utils/safe_get_cwd.py +2 -1
  78. ripperdoc/utils/session_history.py +13 -6
  79. ripperdoc/utils/todo.py +2 -1
  80. ripperdoc/utils/token_estimation.py +6 -1
  81. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
  82. ripperdoc-0.2.9.dist-info/RECORD +123 -0
  83. ripperdoc-0.2.7.dist-info/RECORD +0 -113
  84. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
  85. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
  86. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
  87. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
@@ -4,37 +4,37 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
4
4
  """
5
5
 
6
6
  import asyncio
7
- import contextlib
8
7
  import json
9
- import os
10
8
  import sys
11
9
  import uuid
12
- import re
13
10
  from typing import List, Dict, Any, Optional, Union, Iterable
14
11
  from pathlib import Path
15
12
 
16
13
  from rich.console import Console
17
- from rich.markdown import Markdown
18
14
  from rich.markup import escape
19
15
 
20
16
  from prompt_toolkit import PromptSession
21
17
  from prompt_toolkit.completion import Completer, Completion, merge_completers
22
- from prompt_toolkit.shortcuts.prompt import CompleteStyle
23
18
  from prompt_toolkit.history import InMemoryHistory
24
19
  from prompt_toolkit.key_binding import KeyBindings
25
- from prompt_toolkit.document import Document
20
+ from prompt_toolkit.shortcuts.prompt import CompleteStyle
26
21
 
27
22
  from ripperdoc.core.config import get_global_config, provider_protocol
28
23
  from ripperdoc.core.default_tools import get_default_tools
29
24
  from ripperdoc.core.query import query, QueryContext
30
25
  from ripperdoc.core.system_prompt import build_system_prompt
31
26
  from ripperdoc.core.skills import build_skill_summary, load_all_skills
27
+ from ripperdoc.core.hooks.manager import hook_manager
32
28
  from ripperdoc.cli.commands import (
33
29
  get_slash_command,
30
+ get_custom_command,
34
31
  list_slash_commands,
32
+ list_custom_commands,
35
33
  slash_command_completions,
34
+ expand_command_content,
35
+ CustomCommandDefinition,
36
36
  )
37
- from ripperdoc.cli.ui.helpers import get_profile_for_pointer, THINKING_WORDS
37
+ from ripperdoc.cli.ui.helpers import get_profile_for_pointer
38
38
  from ripperdoc.core.permissions import make_permission_checker
39
39
  from ripperdoc.cli.ui.spinner import Spinner
40
40
  from ripperdoc.cli.ui.thinking_spinner import ThinkingSpinner
@@ -72,7 +72,6 @@ from ripperdoc.utils.messages import (
72
72
  AssistantMessage,
73
73
  ProgressMessage,
74
74
  create_user_message,
75
- create_assistant_message,
76
75
  )
77
76
  from ripperdoc.utils.log import enable_session_file_logging, get_logger
78
77
  from ripperdoc.utils.path_ignore import build_ignore_filter
@@ -97,15 +96,16 @@ class RichUI:
97
96
 
98
97
  def __init__(
99
98
  self,
100
- safe_mode: bool = False,
99
+ yolo_mode: bool = False,
101
100
  verbose: bool = False,
101
+ show_full_thinking: Optional[bool] = None,
102
102
  session_id: Optional[str] = None,
103
103
  log_file_path: Optional[Path] = None,
104
104
  ):
105
105
  self._loop = asyncio.new_event_loop()
106
106
  asyncio.set_event_loop(self._loop)
107
107
  self.console = console
108
- self.safe_mode = safe_mode
108
+ self.yolo_mode = yolo_mode
109
109
  self.verbose = verbose
110
110
  self.conversation_messages: List[ConversationMessage] = []
111
111
  self._saved_conversation: Optional[List[ConversationMessage]] = None
@@ -113,7 +113,7 @@ class RichUI:
113
113
  self._current_tool: Optional[str] = None
114
114
  self._should_exit: bool = False
115
115
  self.command_list = list_slash_commands()
116
- self._command_completions = slash_command_completions()
116
+ self._custom_command_list = list_custom_commands()
117
117
  self._prompt_session: Optional[PromptSession] = None
118
118
  self.project_path = Path.cwd()
119
119
  # Track a stable session identifier for the current UI run.
@@ -129,16 +129,17 @@ class RichUI:
129
129
  "session_id": self.session_id,
130
130
  "project_path": str(self.project_path),
131
131
  "log_file": str(self.log_file_path),
132
- "safe_mode": self.safe_mode,
132
+ "yolo_mode": self.yolo_mode,
133
133
  "verbose": self.verbose,
134
134
  },
135
135
  )
136
136
  self._session_history = SessionHistory(self.project_path, self.session_id)
137
137
  self._permission_checker = (
138
- make_permission_checker(self.project_path, safe_mode) if safe_mode else None
138
+ None if yolo_mode else make_permission_checker(self.project_path, yolo_mode=False)
139
139
  )
140
140
  # Build ignore filter for file completion
141
141
  from ripperdoc.utils.path_ignore import get_project_ignore_patterns
142
+
142
143
  project_patterns = get_project_ignore_patterns()
143
144
  self._ignore_filter = build_ignore_filter(
144
145
  self.project_path,
@@ -147,8 +148,17 @@ class RichUI:
147
148
  include_gitignore=True,
148
149
  )
149
150
 
151
+ # Get global config for display preferences
152
+ config = get_global_config()
153
+ if show_full_thinking is None:
154
+ self.show_full_thinking = config.show_full_thinking
155
+ else:
156
+ self.show_full_thinking = show_full_thinking
157
+
150
158
  # Initialize component handlers
151
- self._message_display = MessageDisplay(self.console, self.verbose)
159
+ self._message_display = MessageDisplay(
160
+ self.console, self.verbose, self.show_full_thinking
161
+ )
152
162
  self._interrupt_handler = InterruptHandler()
153
163
  self._interrupt_handler.set_abort_callback(self._trigger_abort)
154
164
 
@@ -158,10 +168,22 @@ class RichUI:
158
168
  except (OSError, RuntimeError, ConnectionError) as exc:
159
169
  logger.warning(
160
170
  "[ui] Failed to initialize MCP runtime at startup: %s: %s",
161
- type(exc).__name__, exc,
171
+ type(exc).__name__,
172
+ exc,
162
173
  extra={"session_id": self.session_id},
163
174
  )
164
175
 
176
+ # Initialize hook manager with project context
177
+ hook_manager.set_project_dir(self.project_path)
178
+ hook_manager.set_session_id(self.session_id)
179
+ logger.debug(
180
+ "[ui] Initialized hook manager",
181
+ extra={
182
+ "session_id": self.session_id,
183
+ "project_path": str(self.project_path),
184
+ },
185
+ )
186
+
165
187
  # ─────────────────────────────────────────────────────────────────────────────
166
188
  # Properties for backward compatibility with interrupt handler
167
189
  # ─────────────────────────────────────────────────────────────────────────────
@@ -205,7 +227,8 @@ class RichUI:
205
227
  # Logging failures should never interrupt the UI flow
206
228
  logger.warning(
207
229
  "[ui] Failed to append message to session history: %s: %s",
208
- type(exc).__name__, exc,
230
+ type(exc).__name__,
231
+ exc,
209
232
  extra={"session_id": self.session_id},
210
233
  )
211
234
 
@@ -219,7 +242,8 @@ class RichUI:
219
242
  except (AttributeError, TypeError, ValueError) as exc:
220
243
  logger.warning(
221
244
  "[ui] Failed to append prompt history: %s: %s",
222
- type(exc).__name__, exc,
245
+ type(exc).__name__,
246
+ exc,
223
247
  extra={"session_id": self.session_id},
224
248
  )
225
249
 
@@ -452,7 +476,7 @@ class RichUI:
452
476
  "tokens_saved": result.tokens_saved,
453
477
  },
454
478
  )
455
- return result.messages # type: ignore[return-value]
479
+ return result.messages
456
480
  elif isinstance(result, CompactionError):
457
481
  logger.warning(
458
482
  "[ui] Auto-compaction failed: %s",
@@ -466,29 +490,36 @@ class RichUI:
466
490
  self,
467
491
  message: AssistantMessage,
468
492
  tool_registry: Dict[str, Dict[str, Any]],
493
+ spinner: Optional[ThinkingSpinner] = None,
469
494
  ) -> Optional[str]:
470
495
  """Handle an assistant message from the query stream.
471
496
 
472
497
  Returns:
473
498
  The last tool name if a tool_use block was processed, None otherwise.
474
499
  """
500
+ # Factory to create pause context - spinner.paused() if spinner exists, else no-op
501
+ from contextlib import nullcontext
502
+
503
+ pause = lambda: spinner.paused() if spinner else nullcontext() # noqa: E731
504
+
475
505
  meta = getattr(getattr(message, "message", None), "metadata", {}) or {}
476
506
  reasoning_payload = (
477
- meta.get("reasoning_content")
478
- or meta.get("reasoning")
479
- or meta.get("reasoning_details")
507
+ meta.get("reasoning_content") or meta.get("reasoning") or meta.get("reasoning_details")
480
508
  )
481
509
  if reasoning_payload:
482
- self._print_reasoning(reasoning_payload)
510
+ with pause():
511
+ self._print_reasoning(reasoning_payload)
483
512
 
484
513
  last_tool_name: Optional[str] = None
485
514
 
486
515
  if isinstance(message.message.content, str):
487
- self.display_message("Ripperdoc", message.message.content)
516
+ with pause():
517
+ self.display_message("Ripperdoc", message.message.content)
488
518
  elif isinstance(message.message.content, list):
489
519
  for block in message.message.content:
490
520
  if hasattr(block, "type") and block.type == "text" and block.text:
491
- self.display_message("Ripperdoc", block.text)
521
+ with pause():
522
+ self.display_message("Ripperdoc", block.text)
492
523
  elif hasattr(block, "type") and block.type == "tool_use":
493
524
  tool_name = getattr(block, "name", "unknown tool")
494
525
  tool_args = getattr(block, "input", {})
@@ -502,9 +533,10 @@ class RichUI:
502
533
  }
503
534
 
504
535
  if tool_name == "Task":
505
- self.display_message(
506
- tool_name, "", is_tool=True, tool_type="call", tool_args=tool_args
507
- )
536
+ with pause():
537
+ self.display_message(
538
+ tool_name, "", is_tool=True, tool_type="call", tool_args=tool_args
539
+ )
508
540
  if tool_use_id:
509
541
  tool_registry[tool_use_id]["printed"] = True
510
542
 
@@ -517,11 +549,17 @@ class RichUI:
517
549
  message: UserMessage,
518
550
  tool_registry: Dict[str, Dict[str, Any]],
519
551
  last_tool_name: Optional[str],
552
+ spinner: Optional[ThinkingSpinner] = None,
520
553
  ) -> None:
521
554
  """Handle a user message containing tool results."""
522
555
  if not isinstance(message.message.content, list):
523
556
  return
524
557
 
558
+ # Factory to create pause context - spinner.paused() if spinner exists, else no-op
559
+ from contextlib import nullcontext
560
+
561
+ pause = lambda: spinner.paused() if spinner else nullcontext() # noqa: E731
562
+
525
563
  for block in message.message.content:
526
564
  if not (hasattr(block, "type") and block.type == "tool_result" and block.text):
527
565
  continue
@@ -535,25 +573,27 @@ class RichUI:
535
573
  if entry:
536
574
  tool_name = entry.get("name", tool_name)
537
575
  if not entry.get("printed"):
538
- self.display_message(
539
- tool_name,
540
- "",
541
- is_tool=True,
542
- tool_type="call",
543
- tool_args=entry.get("args", {}),
544
- )
576
+ with pause():
577
+ self.display_message(
578
+ tool_name,
579
+ "",
580
+ is_tool=True,
581
+ tool_type="call",
582
+ tool_args=entry.get("args", {}),
583
+ )
545
584
  entry["printed"] = True
546
585
  elif last_tool_name:
547
586
  tool_name = last_tool_name
548
587
 
549
- self.display_message(
550
- tool_name,
551
- block.text,
552
- is_tool=True,
553
- tool_type="result",
554
- tool_data=tool_data,
555
- tool_error=is_error,
556
- )
588
+ with pause():
589
+ self.display_message(
590
+ tool_name,
591
+ block.text,
592
+ is_tool=True,
593
+ tool_type="result",
594
+ tool_data=tool_data,
595
+ tool_error=is_error,
596
+ )
557
597
 
558
598
  def _handle_progress_message(
559
599
  self,
@@ -567,14 +607,17 @@ class RichUI:
567
607
  Updated output token estimate.
568
608
  """
569
609
  if self.verbose:
570
- self.display_message("System", f"Progress: {message.content}", is_tool=True)
610
+ with spinner.paused():
611
+ self.display_message("System", f"Progress: {message.content}", is_tool=True)
571
612
  elif message.content and isinstance(message.content, str):
572
613
  if message.content.startswith("Subagent: "):
573
- self.display_message(
574
- "Subagent", message.content[len("Subagent: ") :], is_tool=True
575
- )
614
+ with spinner.paused():
615
+ self.display_message(
616
+ "Subagent", message.content[len("Subagent: ") :], is_tool=True
617
+ )
576
618
  elif message.content.startswith("Subagent"):
577
- self.display_message("Subagent", message.content, is_tool=True)
619
+ with spinner.paused():
620
+ self.display_message("Subagent", message.content, is_tool=True)
578
621
 
579
622
  if message.tool_use_id == "stream":
580
623
  delta_tokens = estimate_tokens(message.content)
@@ -590,7 +633,7 @@ class RichUI:
590
633
  # Initialize or reset query context
591
634
  if not self.query_context:
592
635
  self.query_context = QueryContext(
593
- tools=self.get_default_tools(), safe_mode=self.safe_mode, verbose=self.verbose
636
+ tools=self.get_default_tools(), yolo_mode=self.yolo_mode, verbose=self.verbose
594
637
  )
595
638
  else:
596
639
  abort_controller = getattr(self.query_context, "abort_controller", None)
@@ -674,7 +717,8 @@ class RichUI:
674
717
  except (RuntimeError, ValueError, OSError) as exc:
675
718
  logger.debug(
676
719
  "[ui] Failed to restart spinner after permission check: %s: %s",
677
- type(exc).__name__, exc,
720
+ type(exc).__name__,
721
+ exc,
678
722
  )
679
723
 
680
724
  # Process query stream
@@ -692,12 +736,14 @@ class RichUI:
692
736
  permission_checker, # type: ignore[arg-type]
693
737
  ):
694
738
  if message.type == "assistant" and isinstance(message, AssistantMessage):
695
- result = self._handle_assistant_message(message, tool_registry)
739
+ result = self._handle_assistant_message(message, tool_registry, spinner)
696
740
  if result:
697
741
  last_tool_name = result
698
742
 
699
743
  elif message.type == "user" and isinstance(message, UserMessage):
700
- self._handle_tool_result_message(message, tool_registry, last_tool_name)
744
+ self._handle_tool_result_message(
745
+ message, tool_registry, last_tool_name, spinner
746
+ )
701
747
 
702
748
  elif message.type == "progress" and isinstance(message, ProgressMessage):
703
749
  output_token_est = self._handle_progress_message(
@@ -713,7 +759,8 @@ class RichUI:
713
759
  except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as e:
714
760
  logger.warning(
715
761
  "[ui] Error while processing streamed query response: %s: %s",
716
- type(e).__name__, e,
762
+ type(e).__name__,
763
+ e,
717
764
  extra={"session_id": self.session_id},
718
765
  )
719
766
  self.display_message("System", f"Error: {str(e)}", is_tool=True)
@@ -723,7 +770,8 @@ class RichUI:
723
770
  except (RuntimeError, ValueError, OSError) as exc:
724
771
  logger.warning(
725
772
  "[ui] Failed to stop spinner: %s: %s",
726
- type(exc).__name__, exc,
773
+ type(exc).__name__,
774
+ exc,
727
775
  extra={"session_id": self.session_id},
728
776
  )
729
777
 
@@ -743,7 +791,8 @@ class RichUI:
743
791
  except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as exc:
744
792
  logger.warning(
745
793
  "[ui] Error during query processing: %s: %s",
746
- type(exc).__name__, exc,
794
+ type(exc).__name__,
795
+ exc,
747
796
  extra={"session_id": self.session_id},
748
797
  )
749
798
  self.display_message("System", f"Error: {str(exc)}", is_tool=True)
@@ -789,8 +838,9 @@ class RichUI:
789
838
  """Public wrapper for running coroutines on the UI event loop."""
790
839
  return self._run_async(coro)
791
840
 
792
- def handle_slash_command(self, user_input: str) -> bool:
793
- """Handle slash commands. Returns True if the input was handled."""
841
+ def handle_slash_command(self, user_input: str) -> bool | str:
842
+ """Handle slash commands. Returns True if handled as built-in, False if not a command,
843
+ or a string if it's a custom command that should be sent to the AI."""
794
844
 
795
845
  if not user_input.startswith("/"):
796
846
  return False
@@ -802,12 +852,28 @@ class RichUI:
802
852
 
803
853
  command_name = parts[0].lower()
804
854
  trimmed_arg = " ".join(parts[1:]).strip()
855
+
856
+ # First, try built-in commands
805
857
  command = get_slash_command(command_name)
806
- if command is None:
807
- self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
808
- return True
858
+ if command is not None:
859
+ return command.handler(self, trimmed_arg)
860
+
861
+ # Then, try custom commands
862
+ custom_cmd = get_custom_command(command_name, self.project_path)
863
+ if custom_cmd is not None:
864
+ # Expand the custom command content
865
+ expanded_content = expand_command_content(custom_cmd, trimmed_arg, self.project_path)
866
+
867
+ # Show a hint that this is from a custom command
868
+ self.console.print(f"[dim]Running custom command: /{command_name}[/dim]")
869
+ if custom_cmd.argument_hint and trimmed_arg:
870
+ self.console.print(f"[dim]Arguments: {trimmed_arg}[/dim]")
871
+
872
+ # Return the expanded content to be processed as a query
873
+ return expanded_content
809
874
 
810
- return command.handler(self, trimmed_arg)
875
+ self.console.print(f"[red]Unknown command: {escape(command_name)}[/red]")
876
+ return True
811
877
 
812
878
  def get_prompt_session(self) -> PromptSession:
813
879
  """Create (or return) the prompt session with command completion."""
@@ -815,35 +881,68 @@ class RichUI:
815
881
  return self._prompt_session
816
882
 
817
883
  class SlashCommandCompleter(Completer):
818
- """Autocomplete for slash commands."""
884
+ """Autocomplete for slash commands including custom commands."""
819
885
 
820
- def __init__(self, completions: List):
821
- self.completions = completions
886
+ def __init__(self, project_path: Path):
887
+ self.project_path = project_path
822
888
 
823
889
  def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
824
890
  text = document.text_before_cursor
825
891
  if not text.startswith("/"):
826
892
  return
827
893
  query = text[1:]
828
- for name, cmd in self.completions:
894
+ # Get completions including custom commands
895
+ completions = slash_command_completions(self.project_path)
896
+ for name, cmd in completions:
829
897
  if name.startswith(query):
898
+ # Handle both SlashCommand and CustomCommandDefinition
899
+ description = cmd.description
900
+ # Add hint for custom commands
901
+ if isinstance(cmd, CustomCommandDefinition):
902
+ hint = cmd.argument_hint or ""
903
+ display = f"{name} {hint}".strip() if hint else name
904
+ display_meta = f"[custom] {description}"
905
+ else:
906
+ display = name
907
+ display_meta = description
830
908
  yield Completion(
831
909
  name,
832
910
  start_position=-len(query),
833
- display=name,
834
- display_meta=cmd.description,
911
+ display=display,
912
+ display_meta=display_meta,
835
913
  )
836
914
 
837
915
  # Merge both completers
838
- slash_completer = SlashCommandCompleter(self._command_completions)
916
+ slash_completer = SlashCommandCompleter(self.project_path)
839
917
  file_completer = FileMentionCompleter(self.project_path, self._ignore_filter)
840
918
  combined_completer = merge_completers([slash_completer, file_completer])
841
919
 
920
+ key_bindings = KeyBindings()
921
+
922
+ @key_bindings.add("enter")
923
+ def _(event: Any) -> None:
924
+ """Accept completion if menu is open; otherwise submit line."""
925
+ buf = event.current_buffer
926
+ if buf.complete_state and buf.complete_state.current_completion:
927
+ buf.apply_completion(buf.complete_state.current_completion)
928
+ return
929
+ buf.validate_and_handle()
930
+
931
+ @key_bindings.add("tab")
932
+ def _(event: Any) -> None:
933
+ """Use Tab to accept the highlighted completion when visible."""
934
+ buf = event.current_buffer
935
+ if buf.complete_state and buf.complete_state.current_completion:
936
+ buf.apply_completion(buf.complete_state.current_completion)
937
+ else:
938
+ buf.start_completion(select_first=True)
939
+
842
940
  self._prompt_session = PromptSession(
843
941
  completer=combined_completer,
844
942
  complete_style=CompleteStyle.COLUMN,
845
943
  complete_while_typing=True,
846
944
  history=InMemoryHistory(),
945
+ key_bindings=key_bindings,
847
946
  )
848
947
  return self._prompt_session
849
948
 
@@ -857,7 +956,9 @@ class RichUI:
857
956
  # Display status
858
957
  console.print(create_status_bar())
859
958
  console.print()
860
- console.print("[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. Press ESC to interrupt a running query.[/dim]\n")
959
+ console.print(
960
+ "[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. Press ESC to interrupt a running query.[/dim]\n"
961
+ )
861
962
 
862
963
  session = self.get_prompt_session()
863
964
  logger.info(
@@ -888,7 +989,11 @@ class RichUI:
888
989
  handled = self.handle_slash_command(user_input)
889
990
  if self._should_exit:
890
991
  break
891
- if handled:
992
+ # If handled is a string, it's expanded custom command content
993
+ if isinstance(handled, str):
994
+ # Process the expanded custom command as a query
995
+ user_input = handled
996
+ elif handled:
892
997
  console.print() # spacing
893
998
  continue
894
999
 
@@ -904,7 +1009,9 @@ class RichUI:
904
1009
  interrupted = self._run_async_with_esc_interrupt(self.process_query(user_input))
905
1010
 
906
1011
  if interrupted:
907
- console.print("\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]")
1012
+ console.print(
1013
+ "\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
1014
+ )
908
1015
  logger.info(
909
1016
  "[ui] Query interrupted by ESC key",
910
1017
  extra={"session_id": self.session_id},
@@ -923,11 +1030,19 @@ class RichUI:
923
1030
  except EOFError:
924
1031
  console.print("\n[yellow]Goodbye![/yellow]")
925
1032
  break
926
- except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as e:
1033
+ except (
1034
+ OSError,
1035
+ ConnectionError,
1036
+ RuntimeError,
1037
+ ValueError,
1038
+ KeyError,
1039
+ TypeError,
1040
+ ) as e:
927
1041
  console.print(f"[red]Error: {escape(str(e))}[/]")
928
1042
  logger.warning(
929
1043
  "[ui] Error in interactive loop: %s: %s",
930
- type(e).__name__, e,
1044
+ type(e).__name__,
1045
+ e,
931
1046
  extra={"session_id": self.session_id},
932
1047
  )
933
1048
  if self.verbose:
@@ -961,7 +1076,8 @@ class RichUI:
961
1076
  # pragma: no cover - defensive shutdown
962
1077
  logger.warning(
963
1078
  "[ui] Failed to shut down MCP runtime cleanly: %s: %s",
964
- type(exc).__name__, exc,
1079
+ type(exc).__name__,
1080
+ exc,
965
1081
  extra={"session_id": self.session_id},
966
1082
  )
967
1083
  finally:
@@ -1014,6 +1130,7 @@ class RichUI:
1014
1130
  )
1015
1131
  except Exception as exc:
1016
1132
  import traceback
1133
+
1017
1134
  self.console.print(f"[red]Error during compaction: {escape(str(exc))}[/red]")
1018
1135
  self.console.print(f"[dim red]{traceback.format_exc()}[/dim red]")
1019
1136
  return
@@ -1042,15 +1159,16 @@ def check_onboarding_rich() -> bool:
1042
1159
  if config.has_completed_onboarding:
1043
1160
  return True
1044
1161
 
1045
- # Use simple console onboarding
1046
- from ripperdoc.cli.cli import check_onboarding
1162
+ # Use the wizard onboarding
1163
+ from ripperdoc.cli.ui.wizard import check_onboarding
1047
1164
 
1048
1165
  return check_onboarding()
1049
1166
 
1050
1167
 
1051
1168
  def main_rich(
1052
- safe_mode: bool = False,
1169
+ yolo_mode: bool = False,
1053
1170
  verbose: bool = False,
1171
+ show_full_thinking: Optional[bool] = None,
1054
1172
  session_id: Optional[str] = None,
1055
1173
  log_file_path: Optional[Path] = None,
1056
1174
  ) -> None:
@@ -1062,8 +1180,9 @@ def main_rich(
1062
1180
 
1063
1181
  # Run the Rich UI
1064
1182
  ui = RichUI(
1065
- safe_mode=safe_mode,
1183
+ yolo_mode=yolo_mode,
1066
1184
  verbose=verbose,
1185
+ show_full_thinking=show_full_thinking,
1067
1186
  session_id=session_id,
1068
1187
  log_file_path=log_file_path,
1069
1188
  )
@@ -1,4 +1,6 @@
1
- from typing import Any, Literal, Optional
1
+ from contextlib import contextmanager
2
+ from typing import Any, Generator, Literal, Optional
3
+
2
4
  from rich.console import Console
3
5
  from rich.markup import escape
4
6
  from rich.status import Status
@@ -47,3 +49,25 @@ class Spinner:
47
49
  self.stop()
48
50
  # Do not suppress exceptions
49
51
  return False
52
+
53
+ @property
54
+ def is_running(self) -> bool:
55
+ """Check if spinner is currently running."""
56
+ return self._status is not None
57
+
58
+ @contextmanager
59
+ def paused(self) -> Generator[None, None, None]:
60
+ """Context manager to temporarily pause the spinner for clean output.
61
+
62
+ Usage:
63
+ with spinner.paused():
64
+ console.print("Some output")
65
+ """
66
+ was_running = self.is_running
67
+ if was_running:
68
+ self.stop()
69
+ try:
70
+ yield
71
+ finally:
72
+ if was_running:
73
+ self.start()
@@ -155,7 +155,10 @@ class BashResultRenderer(ToolResultRenderer):
155
155
  """Render Bash tool results."""
156
156
 
157
157
  def __init__(
158
- self, console: Console, verbose: bool = False, parse_fallback: Optional[BashOutputParser] = None
158
+ self,
159
+ console: Console,
160
+ verbose: bool = False,
161
+ parse_fallback: Optional[BashOutputParser] = None,
159
162
  ):
160
163
  super().__init__(console, verbose)
161
164
  self._parse_fallback = parse_fallback
@@ -254,7 +257,10 @@ class ToolResultRendererRegistry:
254
257
  """Registry that selects the appropriate renderer for a tool result."""
255
258
 
256
259
  def __init__(
257
- self, console: Console, verbose: bool = False, parse_bash_fallback: Optional[BashOutputParser] = None
260
+ self,
261
+ console: Console,
262
+ verbose: bool = False,
263
+ parse_bash_fallback: Optional[BashOutputParser] = None,
258
264
  ):
259
265
  self.console = console
260
266
  self.verbose = verbose