ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__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 (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -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/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,7 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
6
6
  import asyncio
7
7
  import json
8
8
  import sys
9
+ import time
9
10
  import uuid
10
11
  from typing import List, Dict, Any, Optional, Union, Iterable
11
12
  from pathlib import Path
@@ -25,6 +26,7 @@ from ripperdoc.core.query import query, QueryContext
25
26
  from ripperdoc.core.system_prompt import build_system_prompt
26
27
  from ripperdoc.core.skills import build_skill_summary, load_all_skills
27
28
  from ripperdoc.core.hooks.manager import hook_manager
29
+ from ripperdoc.core.hooks.llm_callback import build_hook_llm_callback
28
30
  from ripperdoc.cli.commands import (
29
31
  get_slash_command,
30
32
  get_custom_command,
@@ -64,6 +66,8 @@ from ripperdoc.utils.mcp import (
64
66
  load_mcp_servers_async,
65
67
  shutdown_mcp_runtime,
66
68
  )
69
+ from ripperdoc.utils.lsp import shutdown_lsp_manager
70
+ from ripperdoc.tools.background_shell import shutdown_background_shell
67
71
  from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
68
72
  from ripperdoc.utils.session_history import SessionHistory
69
73
  from ripperdoc.utils.memory import build_memory_instructions
@@ -86,31 +90,37 @@ console = Console()
86
90
  logger = get_logger()
87
91
 
88
92
 
89
- # Legacy aliases for backward compatibility with tests
90
- _extract_tool_ids_from_message = extract_tool_ids_from_message
91
- _get_complete_tool_pairs_tail = get_complete_tool_pairs_tail
92
-
93
-
94
93
  class RichUI:
95
94
  """Rich-based UI for Ripperdoc."""
96
95
 
97
96
  def __init__(
98
97
  self,
99
- safe_mode: bool = False,
98
+ yolo_mode: bool = False,
100
99
  verbose: bool = False,
100
+ show_full_thinking: Optional[bool] = None,
101
101
  session_id: Optional[str] = None,
102
102
  log_file_path: Optional[Path] = None,
103
+ allowed_tools: Optional[List[str]] = None,
104
+ custom_system_prompt: Optional[str] = None,
105
+ append_system_prompt: Optional[str] = None,
106
+ model: Optional[str] = None,
107
+ resume_messages: Optional[List[Any]] = None,
103
108
  ):
104
109
  self._loop = asyncio.new_event_loop()
105
110
  asyncio.set_event_loop(self._loop)
106
111
  self.console = console
107
- self.safe_mode = safe_mode
112
+ self.yolo_mode = yolo_mode
108
113
  self.verbose = verbose
114
+ self.allowed_tools = allowed_tools
115
+ self.custom_system_prompt = custom_system_prompt
116
+ self.append_system_prompt = append_system_prompt
117
+ self.model = model or "main"
109
118
  self.conversation_messages: List[ConversationMessage] = []
110
119
  self._saved_conversation: Optional[List[ConversationMessage]] = None
111
120
  self.query_context: Optional[QueryContext] = None
112
121
  self._current_tool: Optional[str] = None
113
122
  self._should_exit: bool = False
123
+ self._last_ctrl_c_time: float = 0.0 # Track Ctrl+C timing for double-press exit
114
124
  self.command_list = list_slash_commands()
115
125
  self._custom_command_list = list_custom_commands()
116
126
  self._prompt_session: Optional[PromptSession] = None
@@ -128,16 +138,22 @@ class RichUI:
128
138
  "session_id": self.session_id,
129
139
  "project_path": str(self.project_path),
130
140
  "log_file": str(self.log_file_path),
131
- "safe_mode": self.safe_mode,
141
+ "yolo_mode": self.yolo_mode,
132
142
  "verbose": self.verbose,
133
143
  },
134
144
  )
135
145
  self._session_history = SessionHistory(self.project_path, self.session_id)
146
+ self._session_hook_contexts: List[str] = []
147
+ self._session_start_time = time.time()
148
+ self._session_end_sent = False
149
+ self._exit_reason: Optional[str] = None
150
+ hook_manager.set_transcript_path(str(self._session_history.path))
136
151
  self._permission_checker = (
137
- make_permission_checker(self.project_path, safe_mode) if safe_mode else None
152
+ None if yolo_mode else make_permission_checker(self.project_path, yolo_mode=False)
138
153
  )
139
154
  # Build ignore filter for file completion
140
155
  from ripperdoc.utils.path_ignore import get_project_ignore_patterns
156
+
141
157
  project_patterns = get_project_ignore_patterns()
142
158
  self._ignore_filter = build_ignore_filter(
143
159
  self.project_path,
@@ -146,8 +162,17 @@ class RichUI:
146
162
  include_gitignore=True,
147
163
  )
148
164
 
165
+ # Get global config for display preferences
166
+ config = get_global_config()
167
+ if show_full_thinking is None:
168
+ self.show_full_thinking = config.show_full_thinking
169
+ else:
170
+ self.show_full_thinking = show_full_thinking
171
+
149
172
  # Initialize component handlers
150
- self._message_display = MessageDisplay(self.console, self.verbose)
173
+ self._message_display = MessageDisplay(
174
+ self.console, self.verbose, self.show_full_thinking
175
+ )
151
176
  self._interrupt_handler = InterruptHandler()
152
177
  self._interrupt_handler.set_abort_callback(self._trigger_abort)
153
178
 
@@ -157,13 +182,15 @@ class RichUI:
157
182
  except (OSError, RuntimeError, ConnectionError) as exc:
158
183
  logger.warning(
159
184
  "[ui] Failed to initialize MCP runtime at startup: %s: %s",
160
- type(exc).__name__, exc,
185
+ type(exc).__name__,
186
+ exc,
161
187
  extra={"session_id": self.session_id},
162
188
  )
163
189
 
164
190
  # Initialize hook manager with project context
165
191
  hook_manager.set_project_dir(self.project_path)
166
192
  hook_manager.set_session_id(self.session_id)
193
+ hook_manager.set_llm_callback(build_hook_llm_callback())
167
194
  logger.debug(
168
195
  "[ui] Initialized hook manager",
169
196
  extra={
@@ -171,6 +198,18 @@ class RichUI:
171
198
  "project_path": str(self.project_path),
172
199
  },
173
200
  )
201
+ self._run_session_start("startup")
202
+
203
+ # Handle resume_messages if provided (for --continue)
204
+ if resume_messages:
205
+ self.conversation_messages = list(resume_messages)
206
+ logger.info(
207
+ "[ui] Resumed conversation with messages",
208
+ extra={
209
+ "session_id": self.session_id,
210
+ "message_count": len(resume_messages),
211
+ },
212
+ )
174
213
 
175
214
  # ─────────────────────────────────────────────────────────────────────────────
176
215
  # Properties for backward compatibility with interrupt handler
@@ -206,6 +245,69 @@ class RichUI:
206
245
  },
207
246
  )
208
247
  self._session_history = SessionHistory(self.project_path, session_id)
248
+ hook_manager.set_session_id(self.session_id)
249
+ hook_manager.set_transcript_path(str(self._session_history.path))
250
+
251
+ def _collect_hook_contexts(self, hook_result: Any) -> List[str]:
252
+ contexts: List[str] = []
253
+ system_message = getattr(hook_result, "system_message", None)
254
+ additional_context = getattr(hook_result, "additional_context", None)
255
+ if system_message:
256
+ contexts.append(str(system_message))
257
+ if additional_context:
258
+ contexts.append(str(additional_context))
259
+ return contexts
260
+
261
+ def _set_session_hook_contexts(self, hook_result: Any) -> None:
262
+ self._session_hook_contexts = self._collect_hook_contexts(hook_result)
263
+ self._session_start_time = time.time()
264
+ self._session_end_sent = False
265
+
266
+ def _run_session_start(self, source: str) -> None:
267
+ try:
268
+ result = self._run_async(hook_manager.run_session_start_async(source))
269
+ except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
270
+ logger.warning(
271
+ "[ui] SessionStart hook failed: %s: %s",
272
+ type(exc).__name__,
273
+ exc,
274
+ extra={"session_id": self.session_id, "source": source},
275
+ )
276
+ return
277
+ self._set_session_hook_contexts(result)
278
+
279
+ async def _run_session_start_async(self, source: str) -> None:
280
+ try:
281
+ result = await hook_manager.run_session_start_async(source)
282
+ except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
283
+ logger.warning(
284
+ "[ui] SessionStart hook failed: %s: %s",
285
+ type(exc).__name__,
286
+ exc,
287
+ extra={"session_id": self.session_id, "source": source},
288
+ )
289
+ return
290
+ self._set_session_hook_contexts(result)
291
+
292
+ def _run_session_end(self, reason: str) -> None:
293
+ if self._session_end_sent:
294
+ return
295
+ duration = max(time.time() - self._session_start_time, 0.0)
296
+ message_count = len(self.conversation_messages)
297
+ try:
298
+ self._run_async(
299
+ hook_manager.run_session_end_async(
300
+ reason, duration_seconds=duration, message_count=message_count
301
+ )
302
+ )
303
+ except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
304
+ logger.warning(
305
+ "[ui] SessionEnd hook failed: %s: %s",
306
+ type(exc).__name__,
307
+ exc,
308
+ extra={"session_id": self.session_id, "reason": reason},
309
+ )
310
+ self._session_end_sent = True
209
311
 
210
312
  def _log_message(self, message: Any) -> None:
211
313
  """Best-effort persistence of a message to the session log."""
@@ -215,7 +317,8 @@ class RichUI:
215
317
  # Logging failures should never interrupt the UI flow
216
318
  logger.warning(
217
319
  "[ui] Failed to append message to session history: %s: %s",
218
- type(exc).__name__, exc,
320
+ type(exc).__name__,
321
+ exc,
219
322
  extra={"session_id": self.session_id},
220
323
  )
221
324
 
@@ -229,7 +332,8 @@ class RichUI:
229
332
  except (AttributeError, TypeError, ValueError) as exc:
230
333
  logger.warning(
231
334
  "[ui] Failed to append prompt history: %s: %s",
232
- type(exc).__name__, exc,
335
+ type(exc).__name__,
336
+ exc,
233
337
  extra={"session_id": self.session_id},
234
338
  )
235
339
 
@@ -264,8 +368,8 @@ class RichUI:
264
368
  self.display_message("Ripperdoc", text)
265
369
 
266
370
  def get_default_tools(self) -> list:
267
- """Get the default set of tools."""
268
- return get_default_tools()
371
+ """Get the default set of tools, filtered by allowed_tools if specified."""
372
+ return get_default_tools(allowed_tools=self.allowed_tools)
269
373
 
270
374
  def display_message(
271
375
  self,
@@ -320,7 +424,9 @@ class RichUI:
320
424
  def _print_reasoning(self, reasoning: Any) -> None:
321
425
  self._message_display.print_reasoning(reasoning)
322
426
 
323
- async def _prepare_query_context(self, user_input: str) -> tuple[str, Dict[str, str]]:
427
+ async def _prepare_query_context(
428
+ self, user_input: str, hook_instructions: Optional[List[str]] = None
429
+ ) -> tuple[str, Dict[str, str]]:
324
430
  """Load MCP servers, skills, and build system prompt.
325
431
 
326
432
  Returns:
@@ -366,14 +472,32 @@ class RichUI:
366
472
  memory_instructions = build_memory_instructions()
367
473
  if memory_instructions:
368
474
  additional_instructions.append(memory_instructions)
369
-
370
- system_prompt = build_system_prompt(
371
- self.query_context.tools if self.query_context else [],
372
- user_input,
373
- context,
374
- additional_instructions=additional_instructions or None,
375
- mcp_instructions=mcp_instructions,
376
- )
475
+ if self._session_hook_contexts:
476
+ additional_instructions.extend(self._session_hook_contexts)
477
+ if hook_instructions:
478
+ additional_instructions.extend([text for text in hook_instructions if text])
479
+
480
+ # Build system prompt based on options:
481
+ # - custom_system_prompt: replaces the default entirely
482
+ # - append_system_prompt: appends to the default system prompt
483
+ if self.custom_system_prompt:
484
+ # Complete replacement
485
+ system_prompt = self.custom_system_prompt
486
+ # Still append if both are provided
487
+ if self.append_system_prompt:
488
+ system_prompt = f"{system_prompt}\n\n{self.append_system_prompt}"
489
+ else:
490
+ # Build default with optional append
491
+ all_instructions = list(additional_instructions) if additional_instructions else []
492
+ if self.append_system_prompt:
493
+ all_instructions.append(self.append_system_prompt)
494
+ system_prompt = build_system_prompt(
495
+ self.query_context.tools if self.query_context else [],
496
+ user_input,
497
+ context,
498
+ additional_instructions=all_instructions or None,
499
+ mcp_instructions=mcp_instructions,
500
+ )
377
501
 
378
502
  return system_prompt, context
379
503
 
@@ -438,10 +562,33 @@ class RichUI:
438
562
  if usage_status.should_auto_compact:
439
563
  original_messages = list(messages)
440
564
  spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
565
+ hook_instructions = ""
566
+ try:
567
+ hook_result = await hook_manager.run_pre_compact_async(
568
+ trigger="auto", custom_instructions=""
569
+ )
570
+ if hook_result.should_block or not hook_result.should_continue:
571
+ reason = (
572
+ hook_result.block_reason
573
+ or hook_result.stop_reason
574
+ or "Compaction blocked by hook."
575
+ )
576
+ console.print(f"[yellow]{escape(str(reason))}[/yellow]")
577
+ return messages
578
+ hook_contexts = self._collect_hook_contexts(hook_result)
579
+ if hook_contexts:
580
+ hook_instructions = "\n\n".join(hook_contexts)
581
+ except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
582
+ logger.warning(
583
+ "[ui] PreCompact hook failed: %s: %s",
584
+ type(exc).__name__,
585
+ exc,
586
+ extra={"session_id": self.session_id},
587
+ )
441
588
  try:
442
589
  spinner.start()
443
590
  result = await compact_conversation(
444
- messages, custom_instructions="", protocol=protocol
591
+ messages, custom_instructions=hook_instructions, protocol=protocol
445
592
  )
446
593
  finally:
447
594
  spinner.stop()
@@ -462,6 +609,7 @@ class RichUI:
462
609
  "tokens_saved": result.tokens_saved,
463
610
  },
464
611
  )
612
+ await self._run_session_start_async("compact")
465
613
  return result.messages
466
614
  elif isinstance(result, CompactionError):
467
615
  logger.warning(
@@ -476,29 +624,36 @@ class RichUI:
476
624
  self,
477
625
  message: AssistantMessage,
478
626
  tool_registry: Dict[str, Dict[str, Any]],
627
+ spinner: Optional[ThinkingSpinner] = None,
479
628
  ) -> Optional[str]:
480
629
  """Handle an assistant message from the query stream.
481
630
 
482
631
  Returns:
483
632
  The last tool name if a tool_use block was processed, None otherwise.
484
633
  """
634
+ # Factory to create pause context - spinner.paused() if spinner exists, else no-op
635
+ from contextlib import nullcontext
636
+
637
+ pause = lambda: spinner.paused() if spinner else nullcontext() # noqa: E731
638
+
485
639
  meta = getattr(getattr(message, "message", None), "metadata", {}) or {}
486
640
  reasoning_payload = (
487
- meta.get("reasoning_content")
488
- or meta.get("reasoning")
489
- or meta.get("reasoning_details")
641
+ meta.get("reasoning_content") or meta.get("reasoning") or meta.get("reasoning_details")
490
642
  )
491
643
  if reasoning_payload:
492
- self._print_reasoning(reasoning_payload)
644
+ with pause():
645
+ self._print_reasoning(reasoning_payload)
493
646
 
494
647
  last_tool_name: Optional[str] = None
495
648
 
496
649
  if isinstance(message.message.content, str):
497
- self.display_message("Ripperdoc", message.message.content)
650
+ with pause():
651
+ self.display_message("Ripperdoc", message.message.content)
498
652
  elif isinstance(message.message.content, list):
499
653
  for block in message.message.content:
500
654
  if hasattr(block, "type") and block.type == "text" and block.text:
501
- self.display_message("Ripperdoc", block.text)
655
+ with pause():
656
+ self.display_message("Ripperdoc", block.text)
502
657
  elif hasattr(block, "type") and block.type == "tool_use":
503
658
  tool_name = getattr(block, "name", "unknown tool")
504
659
  tool_args = getattr(block, "input", {})
@@ -512,9 +667,10 @@ class RichUI:
512
667
  }
513
668
 
514
669
  if tool_name == "Task":
515
- self.display_message(
516
- tool_name, "", is_tool=True, tool_type="call", tool_args=tool_args
517
- )
670
+ with pause():
671
+ self.display_message(
672
+ tool_name, "", is_tool=True, tool_type="call", tool_args=tool_args
673
+ )
518
674
  if tool_use_id:
519
675
  tool_registry[tool_use_id]["printed"] = True
520
676
 
@@ -527,11 +683,17 @@ class RichUI:
527
683
  message: UserMessage,
528
684
  tool_registry: Dict[str, Dict[str, Any]],
529
685
  last_tool_name: Optional[str],
686
+ spinner: Optional[ThinkingSpinner] = None,
530
687
  ) -> None:
531
688
  """Handle a user message containing tool results."""
532
689
  if not isinstance(message.message.content, list):
533
690
  return
534
691
 
692
+ # Factory to create pause context - spinner.paused() if spinner exists, else no-op
693
+ from contextlib import nullcontext
694
+
695
+ pause = lambda: spinner.paused() if spinner else nullcontext() # noqa: E731
696
+
535
697
  for block in message.message.content:
536
698
  if not (hasattr(block, "type") and block.type == "tool_result" and block.text):
537
699
  continue
@@ -545,25 +707,27 @@ class RichUI:
545
707
  if entry:
546
708
  tool_name = entry.get("name", tool_name)
547
709
  if not entry.get("printed"):
548
- self.display_message(
549
- tool_name,
550
- "",
551
- is_tool=True,
552
- tool_type="call",
553
- tool_args=entry.get("args", {}),
554
- )
710
+ with pause():
711
+ self.display_message(
712
+ tool_name,
713
+ "",
714
+ is_tool=True,
715
+ tool_type="call",
716
+ tool_args=entry.get("args", {}),
717
+ )
555
718
  entry["printed"] = True
556
719
  elif last_tool_name:
557
720
  tool_name = last_tool_name
558
721
 
559
- self.display_message(
560
- tool_name,
561
- block.text,
562
- is_tool=True,
563
- tool_type="result",
564
- tool_data=tool_data,
565
- tool_error=is_error,
566
- )
722
+ with pause():
723
+ self.display_message(
724
+ tool_name,
725
+ block.text,
726
+ is_tool=True,
727
+ tool_type="result",
728
+ tool_data=tool_data,
729
+ tool_error=is_error,
730
+ )
567
731
 
568
732
  def _handle_progress_message(
569
733
  self,
@@ -577,14 +741,17 @@ class RichUI:
577
741
  Updated output token estimate.
578
742
  """
579
743
  if self.verbose:
580
- self.display_message("System", f"Progress: {message.content}", is_tool=True)
744
+ with spinner.paused():
745
+ self.display_message("System", f"Progress: {message.content}", is_tool=True)
581
746
  elif message.content and isinstance(message.content, str):
582
747
  if message.content.startswith("Subagent: "):
583
- self.display_message(
584
- "Subagent", message.content[len("Subagent: ") :], is_tool=True
585
- )
748
+ with spinner.paused():
749
+ self.display_message(
750
+ "Subagent", message.content[len("Subagent: ") :], is_tool=True
751
+ )
586
752
  elif message.content.startswith("Subagent"):
587
- self.display_message("Subagent", message.content, is_tool=True)
753
+ with spinner.paused():
754
+ self.display_message("Subagent", message.content, is_tool=True)
588
755
 
589
756
  if message.tool_use_id == "stream":
590
757
  delta_tokens = estimate_tokens(message.content)
@@ -600,12 +767,16 @@ class RichUI:
600
767
  # Initialize or reset query context
601
768
  if not self.query_context:
602
769
  self.query_context = QueryContext(
603
- tools=self.get_default_tools(), safe_mode=self.safe_mode, verbose=self.verbose
770
+ tools=self.get_default_tools(),
771
+ yolo_mode=self.yolo_mode,
772
+ verbose=self.verbose,
773
+ model=self.model,
604
774
  )
605
775
  else:
606
776
  abort_controller = getattr(self.query_context, "abort_controller", None)
607
777
  if abort_controller is not None:
608
778
  abort_controller.clear()
779
+ self.query_context.stop_hook_active = False
609
780
 
610
781
  logger.info(
611
782
  "[ui] Starting query processing",
@@ -617,8 +788,21 @@ class RichUI:
617
788
  )
618
789
 
619
790
  try:
791
+ hook_result = await hook_manager.run_user_prompt_submit_async(user_input)
792
+ if hook_result.should_block or not hook_result.should_continue:
793
+ reason = (
794
+ hook_result.block_reason
795
+ or hook_result.stop_reason
796
+ or "Prompt blocked by hook."
797
+ )
798
+ self.console.print(f"[red]{escape(str(reason))}[/red]")
799
+ return
800
+ hook_instructions = self._collect_hook_contexts(hook_result)
801
+
620
802
  # Prepare context and system prompt
621
- system_prompt, context = await self._prepare_query_context(user_input)
803
+ system_prompt, context = await self._prepare_query_context(
804
+ user_input, hook_instructions
805
+ )
622
806
 
623
807
  # Create and log user message
624
808
  user_message = create_user_message(user_input)
@@ -684,7 +868,8 @@ class RichUI:
684
868
  except (RuntimeError, ValueError, OSError) as exc:
685
869
  logger.debug(
686
870
  "[ui] Failed to restart spinner after permission check: %s: %s",
687
- type(exc).__name__, exc,
871
+ type(exc).__name__,
872
+ exc,
688
873
  )
689
874
 
690
875
  # Process query stream
@@ -702,12 +887,14 @@ class RichUI:
702
887
  permission_checker, # type: ignore[arg-type]
703
888
  ):
704
889
  if message.type == "assistant" and isinstance(message, AssistantMessage):
705
- result = self._handle_assistant_message(message, tool_registry)
890
+ result = self._handle_assistant_message(message, tool_registry, spinner)
706
891
  if result:
707
892
  last_tool_name = result
708
893
 
709
894
  elif message.type == "user" and isinstance(message, UserMessage):
710
- self._handle_tool_result_message(message, tool_registry, last_tool_name)
895
+ self._handle_tool_result_message(
896
+ message, tool_registry, last_tool_name, spinner
897
+ )
711
898
 
712
899
  elif message.type == "progress" and isinstance(message, ProgressMessage):
713
900
  output_token_est = self._handle_progress_message(
@@ -723,7 +910,8 @@ class RichUI:
723
910
  except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as e:
724
911
  logger.warning(
725
912
  "[ui] Error while processing streamed query response: %s: %s",
726
- type(e).__name__, e,
913
+ type(e).__name__,
914
+ e,
727
915
  extra={"session_id": self.session_id},
728
916
  )
729
917
  self.display_message("System", f"Error: {str(e)}", is_tool=True)
@@ -733,7 +921,8 @@ class RichUI:
733
921
  except (RuntimeError, ValueError, OSError) as exc:
734
922
  logger.warning(
735
923
  "[ui] Failed to stop spinner: %s: %s",
736
- type(exc).__name__, exc,
924
+ type(exc).__name__,
925
+ exc,
737
926
  extra={"session_id": self.session_id},
738
927
  )
739
928
 
@@ -753,7 +942,8 @@ class RichUI:
753
942
  except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as exc:
754
943
  logger.warning(
755
944
  "[ui] Error during query processing: %s: %s",
756
- type(exc).__name__, exc,
945
+ type(exc).__name__,
946
+ exc,
757
947
  extra={"session_id": self.session_id},
758
948
  )
759
949
  self.display_message("System", f"Error: {str(exc)}", is_tool=True)
@@ -823,14 +1013,10 @@ class RichUI:
823
1013
  custom_cmd = get_custom_command(command_name, self.project_path)
824
1014
  if custom_cmd is not None:
825
1015
  # Expand the custom command content
826
- expanded_content = expand_command_content(
827
- custom_cmd, trimmed_arg, self.project_path
828
- )
1016
+ expanded_content = expand_command_content(custom_cmd, trimmed_arg, self.project_path)
829
1017
 
830
1018
  # Show a hint that this is from a custom command
831
- self.console.print(
832
- f"[dim]Running custom command: /{command_name}[/dim]"
833
- )
1019
+ self.console.print(f"[dim]Running custom command: /{command_name}[/dim]")
834
1020
  if custom_cmd.argument_hint and trimmed_arg:
835
1021
  self.console.print(f"[dim]Arguments: {trimmed_arg}[/dim]")
836
1022
 
@@ -851,7 +1037,7 @@ class RichUI:
851
1037
  def __init__(self, project_path: Path):
852
1038
  self.project_path = project_path
853
1039
 
854
- def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
1040
+ def get_completions(self, document: Any, _complete_event: Any) -> Iterable[Completion]:
855
1041
  text = document.text_before_cursor
856
1042
  if not text.startswith("/"):
857
1043
  return
@@ -902,12 +1088,57 @@ class RichUI:
902
1088
  else:
903
1089
  buf.start_completion(select_first=True)
904
1090
 
1091
+ @key_bindings.add("escape", "enter")
1092
+ def _(event: Any) -> None:
1093
+ """Insert newline on Alt+Enter."""
1094
+ event.current_buffer.insert_text("\n")
1095
+
1096
+ # Capture self for use in Ctrl+C handler closure
1097
+ ui_instance = self
1098
+
1099
+ @key_bindings.add("c-c")
1100
+ def _(event: Any) -> None:
1101
+ """Handle Ctrl+C: first press clears input, second press exits."""
1102
+ import time as time_module
1103
+
1104
+ buf = event.current_buffer
1105
+ current_text = buf.text
1106
+ current_time = time_module.time()
1107
+
1108
+ # Check if this is a double Ctrl+C (within 1.5 seconds)
1109
+ if current_time - ui_instance._last_ctrl_c_time < 1.5:
1110
+ # Double Ctrl+C - exit
1111
+ buf.reset()
1112
+ raise KeyboardInterrupt()
1113
+
1114
+ # First Ctrl+C - save to history and clear
1115
+ ui_instance._last_ctrl_c_time = current_time
1116
+
1117
+ if current_text.strip():
1118
+ # Save current input to history before clearing
1119
+ try:
1120
+ event.app.current_buffer.history.append_string(current_text)
1121
+ except (AttributeError, TypeError, ValueError):
1122
+ pass
1123
+
1124
+ # Print hint message in clean terminal context, then clear buffer
1125
+ from prompt_toolkit.application import run_in_terminal
1126
+
1127
+ def _print_hint() -> None:
1128
+ print("\n\033[2mPress Ctrl+C again to exit, or continue typing.\033[0m")
1129
+
1130
+ run_in_terminal(_print_hint)
1131
+
1132
+ # Clear the buffer after printing
1133
+ buf.reset()
1134
+
905
1135
  self._prompt_session = PromptSession(
906
1136
  completer=combined_completer,
907
1137
  complete_style=CompleteStyle.COLUMN,
908
1138
  complete_while_typing=True,
909
1139
  history=InMemoryHistory(),
910
1140
  key_bindings=key_bindings,
1141
+ multiline=True,
911
1142
  )
912
1143
  return self._prompt_session
913
1144
 
@@ -921,7 +1152,10 @@ class RichUI:
921
1152
  # Display status
922
1153
  console.print(create_status_bar())
923
1154
  console.print()
924
- 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")
1155
+ console.print(
1156
+ "[dim]Tip: type '/' then press Tab to see available commands. Type '@' to mention files. "
1157
+ "Press Alt+Enter for newline. Press ESC to interrupt. Press Ctrl+C twice to exit.[/dim]\n"
1158
+ )
925
1159
 
926
1160
  session = self.get_prompt_session()
927
1161
  logger.info(
@@ -929,6 +1163,7 @@ class RichUI:
929
1163
  extra={"session_id": self.session_id, "log_file": str(self.log_file_path)},
930
1164
  )
931
1165
 
1166
+ exit_reason = "other"
932
1167
  try:
933
1168
  while not self._should_exit:
934
1169
  try:
@@ -951,6 +1186,7 @@ class RichUI:
951
1186
  )
952
1187
  handled = self.handle_slash_command(user_input)
953
1188
  if self._should_exit:
1189
+ exit_reason = self._exit_reason or "other"
954
1190
  break
955
1191
  # If handled is a string, it's expanded custom command content
956
1192
  if isinstance(handled, str):
@@ -972,7 +1208,9 @@ class RichUI:
972
1208
  interrupted = self._run_async_with_esc_interrupt(self.process_query(user_input))
973
1209
 
974
1210
  if interrupted:
975
- console.print("\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]")
1211
+ console.print(
1212
+ "\n[red]■ Conversation interrupted[/red] · [dim]Tell the model what to do differently.[/dim]"
1213
+ )
976
1214
  logger.info(
977
1215
  "[ui] Query interrupted by ESC key",
978
1216
  extra={"session_id": self.session_id},
@@ -981,21 +1219,45 @@ class RichUI:
981
1219
  console.print() # Add spacing between interactions
982
1220
 
983
1221
  except KeyboardInterrupt:
1222
+ # Handle Ctrl+C: first press during query aborts it,
1223
+ # double press exits the CLI
1224
+ current_time = time.time()
1225
+
984
1226
  # Signal abort to cancel running queries
985
1227
  if self.query_context:
986
1228
  abort_controller = getattr(self.query_context, "abort_controller", None)
987
1229
  if abort_controller is not None:
988
1230
  abort_controller.set()
989
- console.print("\n[yellow]Goodbye![/yellow]")
990
- break
1231
+
1232
+ # Check if this is a double Ctrl+C (within 1.5 seconds)
1233
+ if current_time - self._last_ctrl_c_time < 1.5:
1234
+ console.print("\n[yellow]Goodbye![/yellow]")
1235
+ exit_reason = "prompt_input_exit"
1236
+ break
1237
+
1238
+ # First Ctrl+C - just abort the query and continue
1239
+ self._last_ctrl_c_time = current_time
1240
+ console.print(
1241
+ "\n[dim]Query interrupted. Press Ctrl+C again to exit.[/dim]"
1242
+ )
1243
+ continue
991
1244
  except EOFError:
992
1245
  console.print("\n[yellow]Goodbye![/yellow]")
1246
+ exit_reason = "prompt_input_exit"
993
1247
  break
994
- except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as e:
1248
+ except (
1249
+ OSError,
1250
+ ConnectionError,
1251
+ RuntimeError,
1252
+ ValueError,
1253
+ KeyError,
1254
+ TypeError,
1255
+ ) as e:
995
1256
  console.print(f"[red]Error: {escape(str(e))}[/]")
996
1257
  logger.warning(
997
1258
  "[ui] Error in interactive loop: %s: %s",
998
- type(e).__name__, e,
1259
+ type(e).__name__,
1260
+ e,
999
1261
  extra={"session_id": self.session_id},
1000
1262
  )
1001
1263
  if self.verbose:
@@ -1009,6 +1271,8 @@ class RichUI:
1009
1271
  if abort_controller is not None:
1010
1272
  abort_controller.set()
1011
1273
 
1274
+ self._run_session_end(exit_reason)
1275
+
1012
1276
  # Suppress async generator cleanup errors during shutdown
1013
1277
  original_hook = sys.unraisablehook
1014
1278
 
@@ -1025,11 +1289,24 @@ class RichUI:
1025
1289
  try:
1026
1290
  try:
1027
1291
  self._run_async(shutdown_mcp_runtime())
1292
+ self._run_async(shutdown_lsp_manager())
1028
1293
  except (OSError, RuntimeError, ConnectionError, asyncio.CancelledError) as exc:
1029
1294
  # pragma: no cover - defensive shutdown
1030
1295
  logger.warning(
1031
1296
  "[ui] Failed to shut down MCP runtime cleanly: %s: %s",
1032
- type(exc).__name__, exc,
1297
+ type(exc).__name__,
1298
+ exc,
1299
+ extra={"session_id": self.session_id},
1300
+ )
1301
+
1302
+ # Shutdown background shell manager to clean up any background tasks
1303
+ try:
1304
+ shutdown_background_shell(force=True)
1305
+ except (OSError, RuntimeError) as exc:
1306
+ logger.debug(
1307
+ "[ui] Failed to shut down background shell cleanly: %s: %s",
1308
+ type(exc).__name__,
1309
+ exc,
1033
1310
  extra={"session_id": self.session_id},
1034
1311
  )
1035
1312
  finally:
@@ -1073,15 +1350,47 @@ class RichUI:
1073
1350
  original_messages = list(self.conversation_messages)
1074
1351
  spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
1075
1352
 
1353
+ hook_instructions = ""
1354
+ try:
1355
+ hook_result = await hook_manager.run_pre_compact_async(
1356
+ trigger="manual", custom_instructions=custom_instructions
1357
+ )
1358
+ if hook_result.should_block or not hook_result.should_continue:
1359
+ reason = (
1360
+ hook_result.block_reason
1361
+ or hook_result.stop_reason
1362
+ or "Compaction blocked by hook."
1363
+ )
1364
+ self.console.print(f"[yellow]{escape(str(reason))}[/yellow]")
1365
+ return
1366
+ hook_contexts = self._collect_hook_contexts(hook_result)
1367
+ if hook_contexts:
1368
+ hook_instructions = "\n\n".join(hook_contexts)
1369
+ except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
1370
+ logger.warning(
1371
+ "[ui] PreCompact hook failed: %s: %s",
1372
+ type(exc).__name__,
1373
+ exc,
1374
+ extra={"session_id": self.session_id},
1375
+ )
1376
+
1377
+ merged_instructions = custom_instructions.strip()
1378
+ if hook_instructions:
1379
+ merged_instructions = (
1380
+ f"{merged_instructions}\n\n{hook_instructions}".strip()
1381
+ if merged_instructions
1382
+ else hook_instructions
1383
+ )
1076
1384
  try:
1077
1385
  spinner.start()
1078
1386
  result = await compact_conversation(
1079
1387
  self.conversation_messages,
1080
- custom_instructions,
1388
+ merged_instructions,
1081
1389
  protocol=protocol,
1082
1390
  )
1083
1391
  except Exception as exc:
1084
1392
  import traceback
1393
+
1085
1394
  self.console.print(f"[red]Error during compaction: {escape(str(exc))}[/red]")
1086
1395
  self.console.print(f"[dim red]{traceback.format_exc()}[/dim red]")
1087
1396
  return
@@ -1095,6 +1404,7 @@ class RichUI:
1095
1404
  f"[green]✓ Conversation compacted[/green] "
1096
1405
  f"(saved ~{result.tokens_saved} tokens). Use /resume to restore full history."
1097
1406
  )
1407
+ await self._run_session_start_async("compact")
1098
1408
  elif isinstance(result, CompactionError):
1099
1409
  self.console.print(f"[red]{escape(result.message)}[/red]")
1100
1410
 
@@ -1110,17 +1420,23 @@ def check_onboarding_rich() -> bool:
1110
1420
  if config.has_completed_onboarding:
1111
1421
  return True
1112
1422
 
1113
- # Use simple console onboarding
1114
- from ripperdoc.cli.cli import check_onboarding
1423
+ # Use the wizard onboarding
1424
+ from ripperdoc.cli.ui.wizard import check_onboarding
1115
1425
 
1116
1426
  return check_onboarding()
1117
1427
 
1118
1428
 
1119
1429
  def main_rich(
1120
- safe_mode: bool = False,
1430
+ yolo_mode: bool = False,
1121
1431
  verbose: bool = False,
1432
+ show_full_thinking: Optional[bool] = None,
1122
1433
  session_id: Optional[str] = None,
1123
1434
  log_file_path: Optional[Path] = None,
1435
+ allowed_tools: Optional[List[str]] = None,
1436
+ custom_system_prompt: Optional[str] = None,
1437
+ append_system_prompt: Optional[str] = None,
1438
+ model: Optional[str] = None,
1439
+ resume_messages: Optional[List[Any]] = None,
1124
1440
  ) -> None:
1125
1441
  """Main entry point for Rich interface."""
1126
1442
 
@@ -1130,10 +1446,16 @@ def main_rich(
1130
1446
 
1131
1447
  # Run the Rich UI
1132
1448
  ui = RichUI(
1133
- safe_mode=safe_mode,
1449
+ yolo_mode=yolo_mode,
1134
1450
  verbose=verbose,
1451
+ show_full_thinking=show_full_thinking,
1135
1452
  session_id=session_id,
1136
1453
  log_file_path=log_file_path,
1454
+ allowed_tools=allowed_tools,
1455
+ custom_system_prompt=custom_system_prompt,
1456
+ append_system_prompt=append_system_prompt,
1457
+ model=model,
1458
+ resume_messages=resume_messages,
1137
1459
  )
1138
1460
  ui.run()
1139
1461