ripperdoc 0.2.9__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 (45) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +235 -14
  3. ripperdoc/cli/commands/__init__.py +2 -0
  4. ripperdoc/cli/commands/agents_cmd.py +132 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/exit_cmd.py +1 -0
  7. ripperdoc/cli/commands/models_cmd.py +3 -3
  8. ripperdoc/cli/commands/resume_cmd.py +4 -0
  9. ripperdoc/cli/commands/stats_cmd.py +244 -0
  10. ripperdoc/cli/ui/panels.py +1 -0
  11. ripperdoc/cli/ui/rich_ui.py +295 -24
  12. ripperdoc/cli/ui/spinner.py +30 -18
  13. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  14. ripperdoc/cli/ui/wizard.py +6 -8
  15. ripperdoc/core/agents.py +10 -3
  16. ripperdoc/core/config.py +3 -6
  17. ripperdoc/core/default_tools.py +90 -10
  18. ripperdoc/core/hooks/events.py +4 -0
  19. ripperdoc/core/hooks/llm_callback.py +59 -0
  20. ripperdoc/core/permissions.py +78 -4
  21. ripperdoc/core/providers/openai.py +29 -19
  22. ripperdoc/core/query.py +192 -31
  23. ripperdoc/core/tool.py +9 -4
  24. ripperdoc/sdk/client.py +77 -2
  25. ripperdoc/tools/background_shell.py +305 -134
  26. ripperdoc/tools/bash_tool.py +42 -13
  27. ripperdoc/tools/file_edit_tool.py +159 -50
  28. ripperdoc/tools/file_read_tool.py +20 -0
  29. ripperdoc/tools/file_write_tool.py +7 -8
  30. ripperdoc/tools/lsp_tool.py +615 -0
  31. ripperdoc/tools/task_tool.py +514 -65
  32. ripperdoc/utils/conversation_compaction.py +1 -1
  33. ripperdoc/utils/file_watch.py +206 -3
  34. ripperdoc/utils/lsp.py +806 -0
  35. ripperdoc/utils/message_formatting.py +5 -2
  36. ripperdoc/utils/messages.py +21 -1
  37. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  38. ripperdoc/utils/session_heatmap.py +244 -0
  39. ripperdoc/utils/session_stats.py +293 -0
  40. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  41. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/RECORD +45 -39
  42. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  43. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  44. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  45. {ripperdoc-0.2.9.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,11 +90,6 @@ 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
 
@@ -101,17 +100,27 @@ class RichUI:
101
100
  show_full_thinking: Optional[bool] = None,
102
101
  session_id: Optional[str] = None,
103
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,
104
108
  ):
105
109
  self._loop = asyncio.new_event_loop()
106
110
  asyncio.set_event_loop(self._loop)
107
111
  self.console = console
108
112
  self.yolo_mode = yolo_mode
109
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"
110
118
  self.conversation_messages: List[ConversationMessage] = []
111
119
  self._saved_conversation: Optional[List[ConversationMessage]] = None
112
120
  self.query_context: Optional[QueryContext] = None
113
121
  self._current_tool: Optional[str] = None
114
122
  self._should_exit: bool = False
123
+ self._last_ctrl_c_time: float = 0.0 # Track Ctrl+C timing for double-press exit
115
124
  self.command_list = list_slash_commands()
116
125
  self._custom_command_list = list_custom_commands()
117
126
  self._prompt_session: Optional[PromptSession] = None
@@ -134,6 +143,11 @@ class RichUI:
134
143
  },
135
144
  )
136
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))
137
151
  self._permission_checker = (
138
152
  None if yolo_mode else make_permission_checker(self.project_path, yolo_mode=False)
139
153
  )
@@ -176,6 +190,7 @@ class RichUI:
176
190
  # Initialize hook manager with project context
177
191
  hook_manager.set_project_dir(self.project_path)
178
192
  hook_manager.set_session_id(self.session_id)
193
+ hook_manager.set_llm_callback(build_hook_llm_callback())
179
194
  logger.debug(
180
195
  "[ui] Initialized hook manager",
181
196
  extra={
@@ -183,6 +198,18 @@ class RichUI:
183
198
  "project_path": str(self.project_path),
184
199
  },
185
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
+ )
186
213
 
187
214
  # ─────────────────────────────────────────────────────────────────────────────
188
215
  # Properties for backward compatibility with interrupt handler
@@ -218,6 +245,69 @@ class RichUI:
218
245
  },
219
246
  )
220
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
221
311
 
222
312
  def _log_message(self, message: Any) -> None:
223
313
  """Best-effort persistence of a message to the session log."""
@@ -278,8 +368,8 @@ class RichUI:
278
368
  self.display_message("Ripperdoc", text)
279
369
 
280
370
  def get_default_tools(self) -> list:
281
- """Get the default set of tools."""
282
- 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)
283
373
 
284
374
  def display_message(
285
375
  self,
@@ -334,7 +424,9 @@ class RichUI:
334
424
  def _print_reasoning(self, reasoning: Any) -> None:
335
425
  self._message_display.print_reasoning(reasoning)
336
426
 
337
- 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]]:
338
430
  """Load MCP servers, skills, and build system prompt.
339
431
 
340
432
  Returns:
@@ -380,14 +472,32 @@ class RichUI:
380
472
  memory_instructions = build_memory_instructions()
381
473
  if memory_instructions:
382
474
  additional_instructions.append(memory_instructions)
383
-
384
- system_prompt = build_system_prompt(
385
- self.query_context.tools if self.query_context else [],
386
- user_input,
387
- context,
388
- additional_instructions=additional_instructions or None,
389
- mcp_instructions=mcp_instructions,
390
- )
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
+ )
391
501
 
392
502
  return system_prompt, context
393
503
 
@@ -452,10 +562,33 @@ class RichUI:
452
562
  if usage_status.should_auto_compact:
453
563
  original_messages = list(messages)
454
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
+ )
455
588
  try:
456
589
  spinner.start()
457
590
  result = await compact_conversation(
458
- messages, custom_instructions="", protocol=protocol
591
+ messages, custom_instructions=hook_instructions, protocol=protocol
459
592
  )
460
593
  finally:
461
594
  spinner.stop()
@@ -476,6 +609,7 @@ class RichUI:
476
609
  "tokens_saved": result.tokens_saved,
477
610
  },
478
611
  )
612
+ await self._run_session_start_async("compact")
479
613
  return result.messages
480
614
  elif isinstance(result, CompactionError):
481
615
  logger.warning(
@@ -633,12 +767,16 @@ class RichUI:
633
767
  # Initialize or reset query context
634
768
  if not self.query_context:
635
769
  self.query_context = QueryContext(
636
- tools=self.get_default_tools(), yolo_mode=self.yolo_mode, verbose=self.verbose
770
+ tools=self.get_default_tools(),
771
+ yolo_mode=self.yolo_mode,
772
+ verbose=self.verbose,
773
+ model=self.model,
637
774
  )
638
775
  else:
639
776
  abort_controller = getattr(self.query_context, "abort_controller", None)
640
777
  if abort_controller is not None:
641
778
  abort_controller.clear()
779
+ self.query_context.stop_hook_active = False
642
780
 
643
781
  logger.info(
644
782
  "[ui] Starting query processing",
@@ -650,8 +788,21 @@ class RichUI:
650
788
  )
651
789
 
652
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
+
653
802
  # Prepare context and system prompt
654
- 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
+ )
655
806
 
656
807
  # Create and log user message
657
808
  user_message = create_user_message(user_input)
@@ -886,7 +1037,7 @@ class RichUI:
886
1037
  def __init__(self, project_path: Path):
887
1038
  self.project_path = project_path
888
1039
 
889
- def get_completions(self, document: Any, complete_event: Any) -> Iterable[Completion]:
1040
+ def get_completions(self, document: Any, _complete_event: Any) -> Iterable[Completion]:
890
1041
  text = document.text_before_cursor
891
1042
  if not text.startswith("/"):
892
1043
  return
@@ -937,12 +1088,57 @@ class RichUI:
937
1088
  else:
938
1089
  buf.start_completion(select_first=True)
939
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
+
940
1135
  self._prompt_session = PromptSession(
941
1136
  completer=combined_completer,
942
1137
  complete_style=CompleteStyle.COLUMN,
943
1138
  complete_while_typing=True,
944
1139
  history=InMemoryHistory(),
945
1140
  key_bindings=key_bindings,
1141
+ multiline=True,
946
1142
  )
947
1143
  return self._prompt_session
948
1144
 
@@ -957,7 +1153,8 @@ class RichUI:
957
1153
  console.print(create_status_bar())
958
1154
  console.print()
959
1155
  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"
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"
961
1158
  )
962
1159
 
963
1160
  session = self.get_prompt_session()
@@ -966,6 +1163,7 @@ class RichUI:
966
1163
  extra={"session_id": self.session_id, "log_file": str(self.log_file_path)},
967
1164
  )
968
1165
 
1166
+ exit_reason = "other"
969
1167
  try:
970
1168
  while not self._should_exit:
971
1169
  try:
@@ -988,6 +1186,7 @@ class RichUI:
988
1186
  )
989
1187
  handled = self.handle_slash_command(user_input)
990
1188
  if self._should_exit:
1189
+ exit_reason = self._exit_reason or "other"
991
1190
  break
992
1191
  # If handled is a string, it's expanded custom command content
993
1192
  if isinstance(handled, str):
@@ -1020,15 +1219,31 @@ class RichUI:
1020
1219
  console.print() # Add spacing between interactions
1021
1220
 
1022
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
+
1023
1226
  # Signal abort to cancel running queries
1024
1227
  if self.query_context:
1025
1228
  abort_controller = getattr(self.query_context, "abort_controller", None)
1026
1229
  if abort_controller is not None:
1027
1230
  abort_controller.set()
1028
- console.print("\n[yellow]Goodbye![/yellow]")
1029
- 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
1030
1244
  except EOFError:
1031
1245
  console.print("\n[yellow]Goodbye![/yellow]")
1246
+ exit_reason = "prompt_input_exit"
1032
1247
  break
1033
1248
  except (
1034
1249
  OSError,
@@ -1056,6 +1271,8 @@ class RichUI:
1056
1271
  if abort_controller is not None:
1057
1272
  abort_controller.set()
1058
1273
 
1274
+ self._run_session_end(exit_reason)
1275
+
1059
1276
  # Suppress async generator cleanup errors during shutdown
1060
1277
  original_hook = sys.unraisablehook
1061
1278
 
@@ -1072,6 +1289,7 @@ class RichUI:
1072
1289
  try:
1073
1290
  try:
1074
1291
  self._run_async(shutdown_mcp_runtime())
1292
+ self._run_async(shutdown_lsp_manager())
1075
1293
  except (OSError, RuntimeError, ConnectionError, asyncio.CancelledError) as exc:
1076
1294
  # pragma: no cover - defensive shutdown
1077
1295
  logger.warning(
@@ -1080,6 +1298,17 @@ class RichUI:
1080
1298
  exc,
1081
1299
  extra={"session_id": self.session_id},
1082
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,
1310
+ extra={"session_id": self.session_id},
1311
+ )
1083
1312
  finally:
1084
1313
  if not self._loop.is_closed():
1085
1314
  # Cancel all pending tasks
@@ -1121,11 +1350,42 @@ class RichUI:
1121
1350
  original_messages = list(self.conversation_messages)
1122
1351
  spinner = Spinner(self.console, "Summarizing conversation...", spinner="dots")
1123
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
+ )
1124
1384
  try:
1125
1385
  spinner.start()
1126
1386
  result = await compact_conversation(
1127
1387
  self.conversation_messages,
1128
- custom_instructions,
1388
+ merged_instructions,
1129
1389
  protocol=protocol,
1130
1390
  )
1131
1391
  except Exception as exc:
@@ -1144,6 +1404,7 @@ class RichUI:
1144
1404
  f"[green]✓ Conversation compacted[/green] "
1145
1405
  f"(saved ~{result.tokens_saved} tokens). Use /resume to restore full history."
1146
1406
  )
1407
+ await self._run_session_start_async("compact")
1147
1408
  elif isinstance(result, CompactionError):
1148
1409
  self.console.print(f"[red]{escape(result.message)}[/red]")
1149
1410
 
@@ -1171,6 +1432,11 @@ def main_rich(
1171
1432
  show_full_thinking: Optional[bool] = None,
1172
1433
  session_id: Optional[str] = None,
1173
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,
1174
1440
  ) -> None:
1175
1441
  """Main entry point for Rich interface."""
1176
1442
 
@@ -1185,6 +1451,11 @@ def main_rich(
1185
1451
  show_full_thinking=show_full_thinking,
1186
1452
  session_id=session_id,
1187
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,
1188
1459
  )
1189
1460
  ui.run()
1190
1461
 
@@ -2,44 +2,56 @@ from contextlib import contextmanager
2
2
  from typing import Any, Generator, Literal, Optional
3
3
 
4
4
  from rich.console import Console
5
- from rich.markup import escape
6
- from rich.status import Status
5
+ from rich.live import Live
6
+ from rich.text import Text
7
+ from rich.spinner import Spinner as RichSpinner
7
8
 
8
9
 
9
10
  class Spinner:
10
- """Lightweight spinner wrapper for Rich status."""
11
+ """Lightweight spinner wrapper that plays nicely with other console output."""
11
12
 
12
13
  def __init__(self, console: Console, text: str = "Thinking...", spinner: str = "dots"):
13
14
  self.console = console
14
15
  self.text = text
15
16
  self.spinner = spinner
16
- self._status: Optional[Status] = None
17
+ self._style = "cyan"
18
+ self._live: Optional[Live] = None
19
+ # Blue spinner for clearer visual separation in the terminal (icon + text)
20
+ self._renderable: RichSpinner = RichSpinner(
21
+ spinner, text=Text(self.text, style=self._style), style=self._style
22
+ )
17
23
 
18
24
  def start(self) -> None:
19
25
  """Start the spinner if not already running."""
20
-
21
- if self._status is not None:
26
+ if self._live is not None:
22
27
  return
23
- self._status = self.console.status(
24
- f"[cyan]{escape(self.text)}[/cyan]", spinner=self.spinner
28
+ self._renderable.text = Text(self.text, style=self._style)
29
+ self._live = Live(
30
+ self._renderable,
31
+ console=self.console,
32
+ transient=True, # Remove spinner line when stopped to avoid layout glitches
33
+ refresh_per_second=12,
25
34
  )
26
- self._status.__enter__()
35
+ self._live.start()
27
36
 
28
37
  def update(self, text: Optional[str] = None) -> None:
29
38
  """Update spinner text."""
30
-
31
- if self._status is None:
39
+ if self._live is None:
32
40
  return
33
- new_text = text if text is not None else self.text
34
- self._status.update(f"[cyan]{escape(new_text)}[/cyan]")
41
+ if text is not None:
42
+ self.text = text
43
+ self._renderable.text = Text(self.text, style=self._style)
44
+ # Live.refresh() redraws the current renderable
45
+ self._live.refresh()
35
46
 
36
47
  def stop(self) -> None:
37
48
  """Stop the spinner if running."""
38
-
39
- if self._status is None:
49
+ if self._live is None:
40
50
  return
41
- self._status.__exit__(None, None, None)
42
- self._status = None
51
+ try:
52
+ self._live.stop()
53
+ finally:
54
+ self._live = None
43
55
 
44
56
  def __enter__(self) -> "Spinner":
45
57
  self.start()
@@ -53,7 +65,7 @@ class Spinner:
53
65
  @property
54
66
  def is_running(self) -> bool:
55
67
  """Check if spinner is currently running."""
56
- return self._status is not None
68
+ return self._live is not None
57
69
 
58
70
  @contextmanager
59
71
  def paused(self) -> Generator[None, None, None]:
@@ -22,7 +22,6 @@ THINKING_WORDS: list[str] = [
22
22
  "Cerebrating",
23
23
  "Channelling",
24
24
  "Churning",
25
- "Clauding",
26
25
  "Coalescing",
27
26
  "Cogitating",
28
27
  "Computing",
@@ -114,7 +113,7 @@ class ThinkingSpinner(Spinner):
114
113
 
115
114
  def _format_text(self, suffix: Optional[str] = None) -> str:
116
115
  elapsed = int(time.monotonic() - self.start_time)
117
- base = f" {self.thinking_word}… (esc to interrupt · {elapsed}s"
116
+ base = f" {self.thinking_word}… (esc to interrupt · {elapsed}s"
118
117
  if self.out_tokens > 0:
119
118
  base += f" · ↓ {self.out_tokens} tokens"
120
119
  else:
@@ -93,14 +93,12 @@ def run_onboarding_wizard(config: GlobalConfig) -> bool:
93
93
  model_suggestions=(),
94
94
  )
95
95
  else:
96
- provider_option = KNOWN_PROVIDERS.get(provider_choice)
97
- if provider_option is None:
98
- provider_option = ProviderOption(
99
- key=provider_choice,
100
- protocol=ProviderType.OPENAI_COMPATIBLE,
101
- default_model=default_model_for_protocol(ProviderType.OPENAI_COMPATIBLE),
102
- model_suggestions=(),
103
- )
96
+ provider_option = KNOWN_PROVIDERS.get(provider_choice) or ProviderOption(
97
+ key=provider_choice,
98
+ protocol=ProviderType.OPENAI_COMPATIBLE,
99
+ default_model=default_model_for_protocol(ProviderType.OPENAI_COMPATIBLE),
100
+ model_suggestions=(),
101
+ )
104
102
 
105
103
  api_key = ""
106
104
  while not api_key: