ripperdoc 0.2.3__py3-none-any.whl → 0.2.5__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 (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +35 -15
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +523 -396
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +172 -4
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +13 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/__init__.py +31 -15
  23. ripperdoc/core/providers/anthropic.py +122 -8
  24. ripperdoc/core/providers/base.py +93 -15
  25. ripperdoc/core/providers/gemini.py +539 -96
  26. ripperdoc/core/providers/openai.py +371 -26
  27. ripperdoc/core/query.py +301 -62
  28. ripperdoc/core/query_utils.py +51 -7
  29. ripperdoc/core/skills.py +295 -0
  30. ripperdoc/core/system_prompt.py +79 -67
  31. ripperdoc/core/tool.py +15 -6
  32. ripperdoc/sdk/client.py +14 -1
  33. ripperdoc/tools/ask_user_question_tool.py +431 -0
  34. ripperdoc/tools/background_shell.py +82 -26
  35. ripperdoc/tools/bash_tool.py +356 -209
  36. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  37. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  38. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  39. ripperdoc/tools/file_edit_tool.py +53 -10
  40. ripperdoc/tools/file_read_tool.py +17 -7
  41. ripperdoc/tools/file_write_tool.py +49 -13
  42. ripperdoc/tools/glob_tool.py +10 -9
  43. ripperdoc/tools/grep_tool.py +182 -51
  44. ripperdoc/tools/ls_tool.py +6 -6
  45. ripperdoc/tools/mcp_tools.py +172 -413
  46. ripperdoc/tools/multi_edit_tool.py +49 -9
  47. ripperdoc/tools/notebook_edit_tool.py +57 -13
  48. ripperdoc/tools/skill_tool.py +205 -0
  49. ripperdoc/tools/task_tool.py +91 -9
  50. ripperdoc/tools/todo_tool.py +12 -12
  51. ripperdoc/tools/tool_search_tool.py +5 -6
  52. ripperdoc/utils/coerce.py +34 -0
  53. ripperdoc/utils/context_length_errors.py +252 -0
  54. ripperdoc/utils/file_watch.py +5 -4
  55. ripperdoc/utils/json_utils.py +4 -4
  56. ripperdoc/utils/log.py +3 -3
  57. ripperdoc/utils/mcp.py +82 -22
  58. ripperdoc/utils/memory.py +9 -6
  59. ripperdoc/utils/message_compaction.py +19 -16
  60. ripperdoc/utils/messages.py +73 -8
  61. ripperdoc/utils/path_ignore.py +677 -0
  62. ripperdoc/utils/permissions/__init__.py +7 -1
  63. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  64. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  65. ripperdoc/utils/prompt.py +1 -1
  66. ripperdoc/utils/safe_get_cwd.py +5 -2
  67. ripperdoc/utils/session_history.py +38 -19
  68. ripperdoc/utils/todo.py +6 -2
  69. ripperdoc/utils/token_estimation.py +34 -0
  70. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
  71. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  72. ripperdoc-0.2.3.dist-info/RECORD +0 -95
  73. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  74. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -4,8 +4,10 @@ This module provides a clean, minimal terminal UI using Rich for the Ripperdoc a
4
4
  """
5
5
 
6
6
  import asyncio
7
+ import json
7
8
  import sys
8
9
  import uuid
10
+ import re
9
11
  from typing import List, Dict, Any, Optional, Union, Iterable
10
12
  from pathlib import Path
11
13
 
@@ -26,6 +28,7 @@ from ripperdoc.core.config import get_global_config, provider_protocol
26
28
  from ripperdoc.core.default_tools import get_default_tools
27
29
  from ripperdoc.core.query import query, QueryContext
28
30
  from ripperdoc.core.system_prompt import build_system_prompt
31
+ from ripperdoc.core.skills import build_skill_summary, load_all_skills
29
32
  from ripperdoc.cli.commands import (
30
33
  get_slash_command,
31
34
  list_slash_commands,
@@ -39,13 +42,14 @@ from ripperdoc.cli.ui.context_display import context_usage_lines
39
42
  from ripperdoc.utils.message_compaction import (
40
43
  compact_messages,
41
44
  estimate_conversation_tokens,
42
- estimate_tokens_from_text,
43
45
  estimate_used_tokens,
44
46
  get_context_usage_status,
45
47
  get_remaining_context_tokens,
46
48
  resolve_auto_compact_enabled,
47
49
  )
50
+ from ripperdoc.utils.token_estimation import estimate_tokens
48
51
  from ripperdoc.utils.mcp import (
52
+ ensure_mcp_runtime,
49
53
  format_mcp_instructions,
50
54
  load_mcp_servers_async,
51
55
  shutdown_mcp_runtime,
@@ -62,6 +66,8 @@ from ripperdoc.utils.messages import (
62
66
  create_assistant_message,
63
67
  )
64
68
  from ripperdoc.utils.log import enable_session_file_logging, get_logger
69
+ from ripperdoc.cli.ui.tool_renderers import ToolResultRendererRegistry
70
+
65
71
 
66
72
  # Type alias for conversation messages
67
73
  ConversationMessage = Union[UserMessage, AssistantMessage, ProgressMessage]
@@ -210,6 +216,8 @@ class RichUI:
210
216
  session_id: Optional[str] = None,
211
217
  log_file_path: Optional[Path] = None,
212
218
  ):
219
+ self._loop = asyncio.new_event_loop()
220
+ asyncio.set_event_loop(self._loop)
213
221
  self.console = console
214
222
  self.safe_mode = safe_mode
215
223
  self.verbose = verbose
@@ -243,6 +251,15 @@ class RichUI:
243
251
  self._permission_checker = (
244
252
  make_permission_checker(self.project_path, safe_mode) if safe_mode else None
245
253
  )
254
+ # Keep MCP runtime alive for the whole UI session. Create it on the UI loop up front.
255
+ try:
256
+ self._run_async(ensure_mcp_runtime(self.project_path))
257
+ except (OSError, RuntimeError, ConnectionError) as exc:
258
+ logger.warning(
259
+ "[ui] Failed to initialize MCP runtime at startup: %s: %s",
260
+ type(exc).__name__, exc,
261
+ extra={"session_id": self.session_id},
262
+ )
246
263
 
247
264
  def _context_usage_lines(
248
265
  self, breakdown: Any, model_label: str, auto_compact_enabled: bool
@@ -267,10 +284,11 @@ class RichUI:
267
284
  """Best-effort persistence of a message to the session log."""
268
285
  try:
269
286
  self._session_history.append(message)
270
- except Exception:
287
+ except (OSError, IOError, json.JSONDecodeError) as exc:
271
288
  # Logging failures should never interrupt the UI flow
272
- logger.exception(
273
- "[ui] Failed to append message to session history",
289
+ logger.warning(
290
+ "[ui] Failed to append message to session history: %s: %s",
291
+ type(exc).__name__, exc,
274
292
  extra={"session_id": self.session_id},
275
293
  )
276
294
 
@@ -281,9 +299,10 @@ class RichUI:
281
299
  session = self.get_prompt_session()
282
300
  try:
283
301
  session.history.append_string(text)
284
- except Exception:
285
- logger.exception(
286
- "[ui] Failed to append prompt history",
302
+ except (AttributeError, TypeError, ValueError) as exc:
303
+ logger.warning(
304
+ "[ui] Failed to append prompt history: %s: %s",
305
+ type(exc).__name__, exc,
287
306
  extra={"session_id": self.session_id},
288
307
  )
289
308
 
@@ -440,7 +459,8 @@ class RichUI:
440
459
  def _print_tool_result(
441
460
  self, sender: str, content: str, tool_data: Any, tool_error: bool = False
442
461
  ) -> None:
443
- """Render a tool result summary."""
462
+ """Render a tool result summary using the renderer registry."""
463
+ # Check for failure states
444
464
  failed = tool_error
445
465
  if tool_data is not None:
446
466
  if isinstance(tool_data, dict):
@@ -448,7 +468,16 @@ class RichUI:
448
468
  else:
449
469
  success = getattr(tool_data, "success", None)
450
470
  failed = failed or (success is False)
471
+ failed = failed or bool(self._get_tool_field(tool_data, "is_error"))
472
+
473
+ # Extract warning/token info
474
+ warning_text = None
475
+ token_estimate = None
476
+ if tool_data is not None:
477
+ warning_text = self._get_tool_field(tool_data, "warning")
478
+ token_estimate = self._get_tool_field(tool_data, "token_estimate")
451
479
 
480
+ # Handle failure case
452
481
  if failed:
453
482
  if content:
454
483
  self.console.print(f" ⎿ [red]{escape(content)}[/red]")
@@ -456,168 +485,29 @@ class RichUI:
456
485
  self.console.print(f" ⎿ [red]{escape(sender)} failed[/red]")
457
486
  return
458
487
 
459
- if not content:
460
- self.console.print(" ⎿ [dim]Tool completed[/]")
461
- return
462
-
463
- if "Todo" in sender:
464
- lines = content.splitlines()
465
- if lines:
466
- self.console.print(f" ⎿ [dim]{escape(lines[0])}[/]")
467
- for line in lines[1:]:
468
- self.console.print(f" {line}", markup=False)
469
- else:
470
- self.console.print(" ⎿ [dim]Todo update[/]")
471
- return
472
-
473
- if "Read" in sender or "View" in sender:
474
- lines = content.split("\n")
475
- line_count = len(lines)
476
- self.console.print(f" ⎿ [dim]Read {line_count} lines[/]")
477
- if self.verbose:
478
- preview = lines[:30]
479
- for line in preview:
480
- self.console.print(line, markup=False)
481
- if len(lines) > len(preview):
482
- self.console.print(f"[dim]... ({len(lines) - len(preview)} more lines)[/]")
483
- return
484
-
485
- if "Write" in sender or "Edit" in sender or "MultiEdit" in sender:
486
- if tool_data and (hasattr(tool_data, "file_path") or isinstance(tool_data, dict)):
487
- file_path = self._get_tool_field(tool_data, "file_path")
488
- additions = self._get_tool_field(tool_data, "additions", 0)
489
- deletions = self._get_tool_field(tool_data, "deletions", 0)
490
- diff_with_line_numbers = self._get_tool_field(
491
- tool_data, "diff_with_line_numbers", []
492
- )
493
-
494
- if not file_path:
495
- self.console.print(" ⎿ [dim]File updated successfully[/]")
496
- return
497
-
488
+ # Display warnings and token estimates
489
+ if warning_text:
490
+ self.console.print(f" ⎿ [yellow]{escape(str(warning_text))}[/yellow]")
491
+ if token_estimate:
498
492
  self.console.print(
499
- f"[dim]Updated {escape(str(file_path))} with {additions} additions and {deletions} removals[/]"
493
+ f" [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
500
494
  )
495
+ elif token_estimate and self.verbose:
496
+ self.console.print(f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]")
501
497
 
502
- if self.verbose:
503
- for line in diff_with_line_numbers:
504
- self.console.print(line, markup=False)
505
- else:
506
- self.console.print(" ⎿ [dim]File updated successfully[/]")
507
- return
508
-
509
- if "Glob" in sender:
510
- files = content.split("\n")
511
- file_count = len([f for f in files if f.strip()])
512
- self.console.print(f" ⎿ [dim]Found {file_count} files[/]")
513
- if self.verbose:
514
- for line in files[:30]:
515
- if line.strip():
516
- self.console.print(f" {line}", markup=False)
517
- if file_count > 30:
518
- self.console.print(f"[dim]... ({file_count - 30} more)[/]")
519
- return
520
-
521
- if "Grep" in sender:
522
- matches = content.split("\n")
523
- match_count = len([m for m in matches if m.strip()])
524
- self.console.print(f" ⎿ [dim]Found {match_count} matches[/]")
525
- if self.verbose:
526
- for line in matches[:30]:
527
- if line.strip():
528
- self.console.print(f" {line}", markup=False)
529
- if match_count > 30:
530
- self.console.print(f"[dim]... ({match_count - 30} more)[/]")
531
- return
532
-
533
- if "LS" in sender:
534
- tree_lines = content.splitlines()
535
- self.console.print(f" ⎿ [dim]Directory tree ({len(tree_lines)} lines)[/]")
536
- if self.verbose:
537
- preview = tree_lines[:40]
538
- for line in preview:
539
- self.console.print(f" {line}", markup=False)
540
- if len(tree_lines) > len(preview):
541
- self.console.print(f"[dim]... ({len(tree_lines) - len(preview)} more)[/]")
498
+ # Handle empty content
499
+ if not content:
500
+ self.console.print(" ⎿ [dim]Tool completed[/]")
542
501
  return
543
502
 
544
- if "Bash" in sender:
545
- stdout = ""
546
- stderr = ""
547
- stdout_lines: List[str] = []
548
- stderr_lines: List[str] = []
549
-
550
- exit_code = 0
551
- duration_ms = 0
552
- timeout_ms = 0
553
-
554
- if tool_data:
555
- exit_code = self._get_tool_field(tool_data, "exit_code", 0)
556
- stdout = self._get_tool_field(tool_data, "stdout", "") or ""
557
- stderr = self._get_tool_field(tool_data, "stderr", "") or ""
558
- duration_ms = self._get_tool_field(tool_data, "duration_ms", 0) or 0
559
- timeout_ms = self._get_tool_field(tool_data, "timeout_ms", 0) or 0
560
- stdout_lines = stdout.splitlines() if stdout else []
561
- stderr_lines = stderr.splitlines() if stderr else []
562
-
563
- if not stdout_lines and not stderr_lines and content:
564
- fallback_stdout, fallback_stderr = self._parse_bash_output_sections(content)
565
- stdout_lines = fallback_stdout
566
- stderr_lines = fallback_stderr
567
-
568
- show_inline_stdout = (
569
- stdout_lines and not stderr_lines and exit_code == 0 and not self.verbose
570
- )
571
-
572
- if show_inline_stdout:
573
- preview = stdout_lines if self.verbose else stdout_lines[:5]
574
- self.console.print(f" ⎿ {preview[0]}", markup=False)
575
- for line in preview[1:]:
576
- self.console.print(f" {line}", markup=False)
577
- if not self.verbose and len(stdout_lines) > len(preview):
578
- self.console.print(
579
- f"[dim]... ({len(stdout_lines) - len(preview)} more lines)[/]"
580
- )
581
- else:
582
- if tool_data:
583
- timing = ""
584
- if duration_ms:
585
- timing = f" ({duration_ms / 1000:.2f}s"
586
- if timeout_ms:
587
- timing += f" / timeout {timeout_ms / 1000:.0f}s"
588
- timing += ")"
589
- elif timeout_ms:
590
- timing = f" (timeout {timeout_ms / 1000:.0f}s)"
591
- self.console.print(f" ⎿ [dim]Exit code {exit_code}{timing}[/]")
592
- else:
593
- self.console.print(" ⎿ [dim]Command executed[/]")
594
-
595
- if stdout_lines:
596
- preview = stdout_lines if self.verbose else stdout_lines[:5]
597
- self.console.print("[dim]stdout:[/]")
598
- for line in preview:
599
- self.console.print(f" {line}", markup=False)
600
- if not self.verbose and len(stdout_lines) > len(preview):
601
- self.console.print(
602
- f"[dim]... ({len(stdout_lines) - len(preview)} more stdout lines)[/]"
603
- )
604
- else:
605
- self.console.print("[dim]stdout:[/]")
606
- self.console.print(" [dim](no stdout)[/]")
607
- if stderr_lines:
608
- preview = stderr_lines if self.verbose else stderr_lines[:5]
609
- self.console.print("[dim]stderr:[/]")
610
- for line in preview:
611
- self.console.print(f" {line}", markup=False)
612
- if not self.verbose and len(stderr_lines) > len(preview):
613
- self.console.print(
614
- f"[dim]... ({len(stderr_lines) - len(preview)} more stderr lines)[/]"
615
- )
616
- else:
617
- self.console.print("[dim]stderr:[/]")
618
- self.console.print(" [dim](no stderr)[/]")
503
+ # Use renderer registry for tool-specific rendering
504
+ registry = ToolResultRendererRegistry(
505
+ self.console, self.verbose, self._parse_bash_output_sections
506
+ )
507
+ if registry.render(sender, content, tool_data):
619
508
  return
620
509
 
510
+ # Fallback for unhandled tools
621
511
  self.console.print(" ⎿ [dim]Tool completed[/]")
622
512
 
623
513
  def _print_generic_tool(self, sender: str, content: str) -> None:
@@ -683,13 +573,40 @@ class RichUI:
683
573
  parts: List[str] = []
684
574
  for block in content:
685
575
  text = getattr(block, "text", None)
576
+ if text is None:
577
+ text = getattr(block, "thinking", None)
686
578
  if not text and isinstance(block, dict):
687
- text = block.get("text")
579
+ text = block.get("text") or block.get("thinking") or block.get("data")
688
580
  if text:
689
581
  parts.append(str(text))
690
582
  return "\n".join(parts)
691
583
  return ""
692
584
 
585
+ def _format_reasoning_preview(self, reasoning: Any) -> str:
586
+ """Best-effort stringify for reasoning/thinking traces."""
587
+ if reasoning is None:
588
+ return ""
589
+ if isinstance(reasoning, str):
590
+ preview = reasoning.strip()
591
+ else:
592
+ try:
593
+ preview = json.dumps(reasoning, ensure_ascii=False)
594
+ except (TypeError, ValueError, OverflowError):
595
+ preview = str(reasoning)
596
+ preview = preview.strip()
597
+ if len(preview) > 4000:
598
+ preview = preview[:4000] + "…"
599
+ return preview
600
+
601
+ def _print_reasoning(self, reasoning: Any) -> None:
602
+ """Display thinking traces in a dim style."""
603
+ preview = self._format_reasoning_preview(reasoning)
604
+ if not preview:
605
+ return
606
+ # Collapse excessive blank lines to keep the thinking block compact.
607
+ preview = re.sub(r"\n{2,}", "\n", preview)
608
+ self.console.print(f"[dim]🧠 Thinking: {escape(preview)}[/]")
609
+
693
610
  def _render_transcript(self, messages: List[ConversationMessage]) -> str:
694
611
  """Render a simple transcript for summarization."""
695
612
  lines: List[str] = []
@@ -717,14 +634,256 @@ class RichUI:
717
634
  return "\n".join(parts)
718
635
  return ""
719
636
 
637
+ async def _prepare_query_context(self, user_input: str) -> tuple[str, Dict[str, str]]:
638
+ """Load MCP servers, skills, and build system prompt.
639
+
640
+ Returns:
641
+ Tuple of (system_prompt, context_dict)
642
+ """
643
+ context: Dict[str, str] = {}
644
+ servers = await load_mcp_servers_async(self.project_path)
645
+ dynamic_tools = await load_dynamic_mcp_tools_async(self.project_path)
646
+
647
+ if dynamic_tools and self.query_context:
648
+ self.query_context.tools = merge_tools_with_dynamic(
649
+ self.query_context.tools, dynamic_tools
650
+ )
651
+
652
+ logger.debug(
653
+ "[ui] Prepared tools and MCP servers",
654
+ extra={
655
+ "session_id": self.session_id,
656
+ "tool_count": len(self.query_context.tools) if self.query_context else 0,
657
+ "mcp_servers": len(servers),
658
+ "dynamic_tools": len(dynamic_tools),
659
+ },
660
+ )
661
+
662
+ mcp_instructions = format_mcp_instructions(servers)
663
+ skill_result = load_all_skills(self.project_path)
664
+
665
+ for err in skill_result.errors:
666
+ logger.warning(
667
+ "[skills] Failed to load skill",
668
+ extra={
669
+ "path": str(err.path),
670
+ "reason": err.reason,
671
+ "session_id": self.session_id,
672
+ },
673
+ )
674
+
675
+ skill_instructions = build_skill_summary(skill_result.skills)
676
+ additional_instructions: List[str] = []
677
+ if skill_instructions:
678
+ additional_instructions.append(skill_instructions)
679
+
680
+ memory_instructions = build_memory_instructions()
681
+ if memory_instructions:
682
+ additional_instructions.append(memory_instructions)
683
+
684
+ system_prompt = build_system_prompt(
685
+ self.query_context.tools if self.query_context else [],
686
+ user_input,
687
+ context,
688
+ additional_instructions=additional_instructions or None,
689
+ mcp_instructions=mcp_instructions,
690
+ )
691
+
692
+ return system_prompt, context
693
+
694
+ def _check_and_compact_messages(
695
+ self,
696
+ messages: List[ConversationMessage],
697
+ max_context_tokens: int,
698
+ auto_compact_enabled: bool,
699
+ protocol: str,
700
+ ) -> List[ConversationMessage]:
701
+ """Check context usage and compact if needed.
702
+
703
+ Returns:
704
+ Possibly compacted list of messages.
705
+ """
706
+ used_tokens = estimate_used_tokens(messages, protocol=protocol) # type: ignore[arg-type]
707
+ usage_status = get_context_usage_status(
708
+ used_tokens, max_context_tokens, auto_compact_enabled
709
+ )
710
+
711
+ logger.debug(
712
+ "[ui] Context usage snapshot",
713
+ extra={
714
+ "session_id": self.session_id,
715
+ "used_tokens": used_tokens,
716
+ "max_context_tokens": max_context_tokens,
717
+ "percent_used": round(usage_status.percent_used, 2),
718
+ "auto_compact_enabled": auto_compact_enabled,
719
+ },
720
+ )
721
+
722
+ if usage_status.is_above_warning:
723
+ console.print(
724
+ f"[yellow]Context usage is {usage_status.percent_used:.1f}% "
725
+ f"({usage_status.total_tokens}/{usage_status.max_context_tokens} tokens).[/yellow]"
726
+ )
727
+ if not auto_compact_enabled:
728
+ console.print(
729
+ "[dim]Auto-compaction is disabled; run /compact to trim history.[/dim]"
730
+ )
731
+
732
+ if usage_status.should_auto_compact:
733
+ original_messages = list(messages)
734
+ compaction = compact_messages(messages, protocol=protocol) # type: ignore[arg-type]
735
+ if compaction.was_compacted:
736
+ if self._saved_conversation is None:
737
+ self._saved_conversation = original_messages # type: ignore[assignment]
738
+ console.print(
739
+ f"[yellow]Auto-compacted conversation (saved ~{compaction.tokens_saved} tokens). "
740
+ f"Estimated usage: {compaction.tokens_after}/{max_context_tokens} tokens.[/yellow]"
741
+ )
742
+ logger.info(
743
+ "[ui] Auto-compacted conversation",
744
+ extra={
745
+ "session_id": self.session_id,
746
+ "tokens_before": compaction.tokens_before,
747
+ "tokens_after": compaction.tokens_after,
748
+ "tokens_saved": compaction.tokens_saved,
749
+ "cleared_tool_ids": list(compaction.cleared_tool_ids),
750
+ },
751
+ )
752
+ return compaction.messages # type: ignore[return-value]
753
+
754
+ return messages
755
+
756
+ def _handle_assistant_message(
757
+ self,
758
+ message: AssistantMessage,
759
+ tool_registry: Dict[str, Dict[str, Any]],
760
+ ) -> Optional[str]:
761
+ """Handle an assistant message from the query stream.
762
+
763
+ Returns:
764
+ The last tool name if a tool_use block was processed, None otherwise.
765
+ """
766
+ meta = getattr(getattr(message, "message", None), "metadata", {}) or {}
767
+ reasoning_payload = (
768
+ meta.get("reasoning_content")
769
+ or meta.get("reasoning")
770
+ or meta.get("reasoning_details")
771
+ )
772
+ if reasoning_payload:
773
+ self._print_reasoning(reasoning_payload)
774
+
775
+ last_tool_name: Optional[str] = None
776
+
777
+ if isinstance(message.message.content, str):
778
+ self.display_message("Ripperdoc", message.message.content)
779
+ elif isinstance(message.message.content, list):
780
+ for block in message.message.content:
781
+ if hasattr(block, "type") and block.type == "text" and block.text:
782
+ self.display_message("Ripperdoc", block.text)
783
+ elif hasattr(block, "type") and block.type == "tool_use":
784
+ tool_name = getattr(block, "name", "unknown tool")
785
+ tool_args = getattr(block, "input", {})
786
+ tool_use_id = getattr(block, "tool_use_id", None) or getattr(block, "id", None)
787
+
788
+ if tool_use_id:
789
+ tool_registry[tool_use_id] = {
790
+ "name": tool_name,
791
+ "args": tool_args,
792
+ "printed": False,
793
+ }
794
+
795
+ if tool_name == "Task":
796
+ self.display_message(
797
+ tool_name, "", is_tool=True, tool_type="call", tool_args=tool_args
798
+ )
799
+ if tool_use_id:
800
+ tool_registry[tool_use_id]["printed"] = True
801
+
802
+ last_tool_name = tool_name
803
+
804
+ return last_tool_name
805
+
806
+ def _handle_tool_result_message(
807
+ self,
808
+ message: UserMessage,
809
+ tool_registry: Dict[str, Dict[str, Any]],
810
+ last_tool_name: Optional[str],
811
+ ) -> None:
812
+ """Handle a user message containing tool results."""
813
+ if not isinstance(message.message.content, list):
814
+ return
815
+
816
+ for block in message.message.content:
817
+ if not (hasattr(block, "type") and block.type == "tool_result" and block.text):
818
+ continue
819
+
820
+ tool_name = "Tool"
821
+ tool_data = getattr(message, "tool_use_result", None)
822
+ is_error = bool(getattr(block, "is_error", False))
823
+ tool_use_id = getattr(block, "tool_use_id", None)
824
+
825
+ entry = tool_registry.get(tool_use_id) if tool_use_id else None
826
+ if entry:
827
+ tool_name = entry.get("name", tool_name)
828
+ if not entry.get("printed"):
829
+ self.display_message(
830
+ tool_name,
831
+ "",
832
+ is_tool=True,
833
+ tool_type="call",
834
+ tool_args=entry.get("args", {}),
835
+ )
836
+ entry["printed"] = True
837
+ elif last_tool_name:
838
+ tool_name = last_tool_name
839
+
840
+ self.display_message(
841
+ tool_name,
842
+ block.text,
843
+ is_tool=True,
844
+ tool_type="result",
845
+ tool_data=tool_data,
846
+ tool_error=is_error,
847
+ )
848
+
849
+ def _handle_progress_message(
850
+ self,
851
+ message: ProgressMessage,
852
+ spinner: ThinkingSpinner,
853
+ output_token_est: int,
854
+ ) -> int:
855
+ """Handle a progress message and update spinner.
856
+
857
+ Returns:
858
+ Updated output token estimate.
859
+ """
860
+ if self.verbose:
861
+ self.display_message("System", f"Progress: {message.content}", is_tool=True)
862
+ elif message.content and isinstance(message.content, str):
863
+ if message.content.startswith("Subagent: "):
864
+ self.display_message(
865
+ "Subagent", message.content[len("Subagent: ") :], is_tool=True
866
+ )
867
+ elif message.content.startswith("Subagent"):
868
+ self.display_message("Subagent", message.content, is_tool=True)
869
+
870
+ if message.tool_use_id == "stream":
871
+ delta_tokens = estimate_tokens(message.content)
872
+ output_token_est += delta_tokens
873
+ spinner.update_tokens(output_token_est)
874
+ else:
875
+ spinner.update_tokens(output_token_est, suffix=f"Working... {message.content}")
876
+
877
+ return output_token_est
878
+
720
879
  async def process_query(self, user_input: str) -> None:
721
880
  """Process a user query and display the response."""
881
+ # Initialize or reset query context
722
882
  if not self.query_context:
723
883
  self.query_context = QueryContext(
724
884
  tools=self.get_default_tools(), safe_mode=self.safe_mode, verbose=self.verbose
725
885
  )
726
886
  else:
727
- # Clear any prior abort so new queries aren't immediately interrupted.
728
887
  abort_controller = getattr(self.query_context, "abort_controller", None)
729
888
  if abort_controller is not None:
730
889
  abort_controller.clear()
@@ -739,42 +898,16 @@ class RichUI:
739
898
  )
740
899
 
741
900
  try:
742
- context: Dict[str, str] = {}
743
- servers = await load_mcp_servers_async(self.project_path)
744
- dynamic_tools = await load_dynamic_mcp_tools_async(self.project_path)
745
- if dynamic_tools:
746
- self.query_context.tools = merge_tools_with_dynamic(
747
- self.query_context.tools, dynamic_tools
748
- )
749
- logger.debug(
750
- "[ui] Prepared tools and MCP servers",
751
- extra={
752
- "session_id": self.session_id,
753
- "tool_count": len(self.query_context.tools),
754
- "mcp_servers": len(servers),
755
- "dynamic_tools": len(dynamic_tools),
756
- },
757
- )
758
- mcp_instructions = format_mcp_instructions(servers)
759
- base_system_prompt = build_system_prompt(
760
- self.query_context.tools,
761
- user_input,
762
- context,
763
- mcp_instructions=mcp_instructions,
764
- )
765
- memory_instructions = build_memory_instructions()
766
- system_prompt = (
767
- f"{base_system_prompt}\n\n{memory_instructions}"
768
- if memory_instructions
769
- else base_system_prompt
770
- )
901
+ # Prepare context and system prompt
902
+ system_prompt, context = await self._prepare_query_context(user_input)
771
903
 
772
- # Create user message
904
+ # Create and log user message
773
905
  user_message = create_user_message(user_input)
774
- messages = self.conversation_messages + [user_message]
906
+ messages: List[ConversationMessage] = self.conversation_messages + [user_message]
775
907
  self._log_message(user_message)
776
908
  self._append_prompt_history(user_input)
777
909
 
910
+ # Get model configuration
778
911
  config = get_global_config()
779
912
  model_profile = get_profile_for_pointer("main")
780
913
  max_context_tokens = get_remaining_context_tokens(
@@ -783,74 +916,59 @@ class RichUI:
783
916
  auto_compact_enabled = resolve_auto_compact_enabled(config)
784
917
  protocol = provider_protocol(model_profile.provider) if model_profile else "openai"
785
918
 
786
- used_tokens = estimate_used_tokens(messages, protocol=protocol) # type: ignore[arg-type]
787
- usage_status = get_context_usage_status(
788
- used_tokens, max_context_tokens, auto_compact_enabled
789
- )
790
- logger.debug(
791
- "[ui] Context usage snapshot",
792
- extra={
793
- "session_id": self.session_id,
794
- "used_tokens": used_tokens,
795
- "max_context_tokens": max_context_tokens,
796
- "percent_used": round(usage_status.percent_used, 2),
797
- "auto_compact_enabled": auto_compact_enabled,
798
- },
919
+ # Check and potentially compact messages
920
+ messages = self._check_and_compact_messages(
921
+ messages, max_context_tokens, auto_compact_enabled, protocol
799
922
  )
800
923
 
801
- if usage_status.is_above_warning:
802
- console.print(
803
- f"[yellow]Context usage is {usage_status.percent_used:.1f}% "
804
- f"({usage_status.total_tokens}/{usage_status.max_context_tokens} tokens).[/yellow]"
805
- )
806
- if not auto_compact_enabled:
807
- console.print(
808
- "[dim]Auto-compaction is disabled; run /compact to trim history.[/dim]"
809
- )
810
-
811
- if usage_status.should_auto_compact:
812
- original_messages = list(messages)
813
- compaction = compact_messages(messages, protocol=protocol) # type: ignore[arg-type]
814
- if compaction.was_compacted:
815
- if self._saved_conversation is None:
816
- self._saved_conversation = original_messages # type: ignore[assignment]
817
- messages = compaction.messages # type: ignore[assignment]
818
- console.print(
819
- f"[yellow]Auto-compacted conversation (saved ~{compaction.tokens_saved} tokens). "
820
- f"Estimated usage: {compaction.tokens_after}/{max_context_tokens} tokens.[/yellow]"
821
- )
822
- logger.info(
823
- "[ui] Auto-compacted conversation",
824
- extra={
825
- "session_id": self.session_id,
826
- "tokens_before": compaction.tokens_before,
827
- "tokens_after": compaction.tokens_after,
828
- "tokens_saved": compaction.tokens_saved,
829
- "cleared_tool_ids": list(compaction.cleared_tool_ids),
830
- },
831
- )
832
-
924
+ # Setup spinner and callbacks
833
925
  prompt_tokens_est = estimate_conversation_tokens(messages, protocol=protocol)
834
926
  spinner = ThinkingSpinner(console, prompt_tokens_est)
835
- # Wrap permission checker to pause the spinner while waiting for user input.
927
+
928
+ def pause_ui() -> None:
929
+ spinner.stop()
930
+
931
+ def resume_ui() -> None:
932
+ spinner.start()
933
+ spinner.update("Thinking...")
934
+
935
+ self.query_context.pause_ui = pause_ui
936
+ self.query_context.resume_ui = resume_ui
937
+
938
+ # Create permission checker with spinner control
836
939
  base_permission_checker = self._permission_checker
837
940
 
838
941
  async def permission_checker(tool: Any, parsed_input: Any) -> bool:
839
- if spinner:
840
- spinner.stop()
942
+ spinner.stop()
841
943
  try:
842
944
  if base_permission_checker is not None:
843
945
  result = await base_permission_checker(tool, parsed_input)
844
- return result.result if hasattr(result, "result") else True
946
+ allowed = result.result if hasattr(result, "result") else True
947
+ logger.debug(
948
+ "[ui] Permission check result",
949
+ extra={
950
+ "tool": getattr(tool, "name", None),
951
+ "allowed": allowed,
952
+ "session_id": self.session_id,
953
+ },
954
+ )
955
+ return allowed
845
956
  return True
846
957
  finally:
847
- if spinner:
958
+ # Wrap spinner restart in try-except to prevent exceptions
959
+ # from discarding the permission result
960
+ try:
848
961
  spinner.start()
849
962
  spinner.update("Thinking...")
963
+ except (RuntimeError, ValueError, OSError) as exc:
964
+ logger.debug(
965
+ "[ui] Failed to restart spinner after permission check: %s: %s",
966
+ type(exc).__name__, exc,
967
+ )
850
968
 
851
- # Track tool uses by ID so results align even when multiple tools fire.
969
+ # Process query stream
852
970
  tool_registry: Dict[str, Dict[str, Any]] = {}
853
- last_tool_name = None
971
+ last_tool_name: Optional[str] = None
854
972
  output_token_est = 0
855
973
 
856
974
  try:
@@ -863,117 +981,41 @@ class RichUI:
863
981
  permission_checker, # type: ignore[arg-type]
864
982
  ):
865
983
  if message.type == "assistant" and isinstance(message, AssistantMessage):
866
- # Extract text content from assistant message
867
- if isinstance(message.message.content, str):
868
- self.display_message("Ripperdoc", message.message.content)
869
- elif isinstance(message.message.content, list):
870
- for block in message.message.content:
871
- if hasattr(block, "type") and block.type == "text" and block.text:
872
- self.display_message("Ripperdoc", block.text)
873
- elif hasattr(block, "type") and block.type == "tool_use":
874
- # Show tool usage in the new format
875
- tool_name = getattr(block, "name", "unknown tool")
876
- tool_args = getattr(block, "input", {})
877
-
878
- tool_use_id = getattr(block, "tool_use_id", None) or getattr(
879
- block, "id", None
880
- )
881
- if tool_use_id:
882
- tool_registry[tool_use_id] = {
883
- "name": tool_name,
884
- "args": tool_args,
885
- "printed": False,
886
- }
887
- if tool_name == "Task":
888
- self.display_message(
889
- tool_name,
890
- "",
891
- is_tool=True,
892
- tool_type="call",
893
- tool_args=tool_args,
894
- )
895
- if tool_use_id:
896
- tool_registry[tool_use_id]["printed"] = True
897
- last_tool_name = tool_name
984
+ result = self._handle_assistant_message(message, tool_registry)
985
+ if result:
986
+ last_tool_name = result
898
987
 
899
988
  elif message.type == "user" and isinstance(message, UserMessage):
900
- # Handle tool results - show summary instead of full content
901
- if isinstance(message.message.content, list):
902
- for block in message.message.content:
903
- if (
904
- hasattr(block, "type")
905
- and block.type == "tool_result"
906
- and block.text
907
- ):
908
- tool_name = "Tool"
909
- tool_data = getattr(message, "tool_use_result", None)
910
- is_error = bool(getattr(block, "is_error", False))
911
-
912
- tool_use_id = getattr(block, "tool_use_id", None)
913
- entry = tool_registry.get(tool_use_id) if tool_use_id else None
914
- if entry:
915
- tool_name = entry.get("name", tool_name)
916
- if not entry.get("printed"):
917
- self.display_message(
918
- tool_name,
919
- "",
920
- is_tool=True,
921
- tool_type="call",
922
- tool_args=entry.get("args", {}),
923
- )
924
- entry["printed"] = True
925
- elif last_tool_name:
926
- tool_name = last_tool_name
927
-
928
- self.display_message(
929
- tool_name,
930
- block.text,
931
- is_tool=True,
932
- tool_type="result",
933
- tool_data=tool_data,
934
- tool_error=is_error,
935
- )
989
+ self._handle_tool_result_message(message, tool_registry, last_tool_name)
936
990
 
937
991
  elif message.type == "progress" and isinstance(message, ProgressMessage):
938
- if self.verbose:
939
- self.display_message(
940
- "System", f"Progress: {message.content}", is_tool=True
941
- )
942
- elif message.content and isinstance(message.content, str):
943
- if message.content.startswith("Subagent: "):
944
- self.display_message(
945
- "Subagent", message.content[len("Subagent: ") :], is_tool=True
946
- )
947
- elif message.content.startswith("Subagent"):
948
- self.display_message("Subagent", message.content, is_tool=True)
949
- if message.tool_use_id == "stream":
950
- delta_tokens = estimate_tokens_from_text(message.content)
951
- output_token_est += delta_tokens
952
- spinner.update_tokens(output_token_est)
953
- else:
954
- spinner.update_tokens(
955
- output_token_est, suffix=f"Working... {message.content}"
956
- )
992
+ output_token_est = self._handle_progress_message(
993
+ message, spinner, output_token_est
994
+ )
957
995
 
958
- # Add message to history
959
996
  self._log_message(message)
960
997
  messages.append(message) # type: ignore[arg-type]
961
- except Exception as e:
962
- logger.exception(
963
- "[ui] Unhandled error while processing streamed query response",
998
+
999
+ except asyncio.CancelledError:
1000
+ # Re-raise cancellation to allow proper cleanup
1001
+ raise
1002
+ except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as e:
1003
+ logger.warning(
1004
+ "[ui] Error while processing streamed query response: %s: %s",
1005
+ type(e).__name__, e,
964
1006
  extra={"session_id": self.session_id},
965
1007
  )
966
1008
  self.display_message("System", f"Error: {str(e)}", is_tool=True)
967
1009
  finally:
968
- # Ensure spinner stops even on exceptions
969
1010
  try:
970
1011
  spinner.stop()
971
- except Exception:
972
- logger.exception(
973
- "[ui] Failed to stop spinner", extra={"session_id": self.session_id}
1012
+ except (RuntimeError, ValueError, OSError) as exc:
1013
+ logger.warning(
1014
+ "[ui] Failed to stop spinner: %s: %s",
1015
+ type(exc).__name__, exc,
1016
+ extra={"session_id": self.session_id},
974
1017
  )
975
1018
 
976
- # Update conversation history
977
1019
  self.conversation_messages = messages
978
1020
  logger.info(
979
1021
  "[ui] Query processing completed",
@@ -983,9 +1025,28 @@ class RichUI:
983
1025
  "project_path": str(self.project_path),
984
1026
  },
985
1027
  )
986
- finally:
987
- await shutdown_mcp_runtime()
988
- await shutdown_mcp_runtime()
1028
+
1029
+ except asyncio.CancelledError:
1030
+ # Re-raise cancellation to allow proper cleanup
1031
+ raise
1032
+ except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as exc:
1033
+ logger.warning(
1034
+ "[ui] Error during query processing: %s: %s",
1035
+ type(exc).__name__, exc,
1036
+ extra={"session_id": self.session_id},
1037
+ )
1038
+ self.display_message("System", f"Error: {str(exc)}", is_tool=True)
1039
+
1040
+ def _run_async(self, coro: Any) -> Any:
1041
+ """Run a coroutine on the persistent event loop."""
1042
+ if self._loop.is_closed():
1043
+ self._loop = asyncio.new_event_loop()
1044
+ asyncio.set_event_loop(self._loop)
1045
+ return self._loop.run_until_complete(coro)
1046
+
1047
+ def run_async(self, coro: Any) -> Any:
1048
+ """Public wrapper for running coroutines on the UI event loop."""
1049
+ return self._run_async(coro)
989
1050
 
990
1051
  def handle_slash_command(self, user_input: str) -> bool:
991
1052
  """Handle slash commands. Returns True if the input was handled."""
@@ -1058,60 +1119,124 @@ class RichUI:
1058
1119
  extra={"session_id": self.session_id, "log_file": str(self.log_file_path)},
1059
1120
  )
1060
1121
 
1061
- while not self._should_exit:
1062
- try:
1063
- # Get user input
1064
- user_input = session.prompt("> ")
1122
+ try:
1123
+ while not self._should_exit:
1124
+ try:
1125
+ # Get user input
1126
+ user_input = session.prompt("> ")
1065
1127
 
1066
- if not user_input.strip():
1067
- continue
1128
+ if not user_input.strip():
1129
+ continue
1068
1130
 
1069
- if user_input.strip() == "?":
1070
- self._print_shortcuts()
1071
- console.print()
1072
- continue
1131
+ if user_input.strip() == "?":
1132
+ self._print_shortcuts()
1133
+ console.print()
1134
+ continue
1073
1135
 
1074
- # Handle slash commands locally
1075
- if user_input.startswith("/"):
1076
- logger.debug(
1077
- "[ui] Received slash command",
1078
- extra={"session_id": self.session_id, "command": user_input},
1136
+ # Handle slash commands locally
1137
+ if user_input.startswith("/"):
1138
+ logger.debug(
1139
+ "[ui] Received slash command",
1140
+ extra={"session_id": self.session_id, "command": user_input},
1141
+ )
1142
+ handled = self.handle_slash_command(user_input)
1143
+ if self._should_exit:
1144
+ break
1145
+ if handled:
1146
+ console.print() # spacing
1147
+ continue
1148
+
1149
+ # Process the query
1150
+ logger.info(
1151
+ "[ui] Processing interactive prompt",
1152
+ extra={
1153
+ "session_id": self.session_id,
1154
+ "prompt_length": len(user_input),
1155
+ "prompt_preview": user_input[:200],
1156
+ },
1079
1157
  )
1080
- handled = self.handle_slash_command(user_input)
1081
- if self._should_exit:
1082
- break
1083
- if handled:
1084
- console.print() # spacing
1085
- continue
1158
+ self._run_async(self.process_query(user_input))
1159
+
1160
+ console.print() # Add spacing between interactions
1161
+
1162
+ except KeyboardInterrupt:
1163
+ # Signal abort to cancel running queries
1164
+ if self.query_context:
1165
+ abort_controller = getattr(self.query_context, "abort_controller", None)
1166
+ if abort_controller is not None:
1167
+ abort_controller.set()
1168
+ console.print("\n[yellow]Goodbye![/yellow]")
1169
+ break
1170
+ except EOFError:
1171
+ console.print("\n[yellow]Goodbye![/yellow]")
1172
+ break
1173
+ except (OSError, ConnectionError, RuntimeError, ValueError, KeyError, TypeError) as e:
1174
+ console.print(f"[red]Error: {escape(str(e))}[/]")
1175
+ logger.warning(
1176
+ "[ui] Error in interactive loop: %s: %s",
1177
+ type(e).__name__, e,
1178
+ extra={"session_id": self.session_id},
1179
+ )
1180
+ if self.verbose:
1181
+ import traceback
1086
1182
 
1087
- # Process the query
1088
- logger.info(
1089
- "[ui] Processing interactive prompt",
1090
- extra={
1091
- "session_id": self.session_id,
1092
- "prompt_length": len(user_input),
1093
- "prompt_preview": user_input[:200],
1094
- },
1095
- )
1096
- asyncio.run(self.process_query(user_input))
1183
+ console.print(traceback.format_exc())
1184
+ finally:
1185
+ # Cancel any running tasks before shutdown
1186
+ if self.query_context:
1187
+ abort_controller = getattr(self.query_context, "abort_controller", None)
1188
+ if abort_controller is not None:
1189
+ abort_controller.set()
1097
1190
 
1098
- console.print() # Add spacing between interactions
1191
+ # Suppress async generator cleanup errors during shutdown
1192
+ original_hook = sys.unraisablehook
1099
1193
 
1100
- except KeyboardInterrupt:
1101
- console.print("\n[yellow]Goodbye![/yellow]")
1102
- break
1103
- except EOFError:
1104
- console.print("\n[yellow]Goodbye![/yellow]")
1105
- break
1106
- except Exception as e:
1107
- console.print(f"[red]Error: {escape(str(e))}[/]")
1108
- logger.exception(
1109
- "[ui] Error in interactive loop", extra={"session_id": self.session_id}
1110
- )
1111
- if self.verbose:
1112
- import traceback
1194
+ def _quiet_unraisable_hook(unraisable: Any) -> None:
1195
+ # Suppress "asynchronous generator is already running" errors during shutdown
1196
+ if isinstance(unraisable.exc_value, RuntimeError):
1197
+ if "asynchronous generator is already running" in str(unraisable.exc_value):
1198
+ return
1199
+ # Call original hook for other errors
1200
+ original_hook(unraisable)
1201
+
1202
+ sys.unraisablehook = _quiet_unraisable_hook
1203
+
1204
+ try:
1205
+ try:
1206
+ self._run_async(shutdown_mcp_runtime())
1207
+ except (OSError, RuntimeError, ConnectionError, asyncio.CancelledError) as exc:
1208
+ # pragma: no cover - defensive shutdown
1209
+ logger.warning(
1210
+ "[ui] Failed to shut down MCP runtime cleanly: %s: %s",
1211
+ type(exc).__name__, exc,
1212
+ extra={"session_id": self.session_id},
1213
+ )
1214
+ finally:
1215
+ if not self._loop.is_closed():
1216
+ # Cancel all pending tasks
1217
+ pending = asyncio.all_tasks(self._loop)
1218
+ for task in pending:
1219
+ task.cancel()
1220
+
1221
+ # Allow cancelled tasks to clean up
1222
+ if pending:
1223
+ try:
1224
+ self._loop.run_until_complete(
1225
+ asyncio.gather(*pending, return_exceptions=True)
1226
+ )
1227
+ except (RuntimeError, asyncio.CancelledError):
1228
+ pass # Ignore errors during task cancellation
1113
1229
 
1114
- console.print(traceback.format_exc())
1230
+ # Shutdown async generators (suppress expected errors)
1231
+ try:
1232
+ self._loop.run_until_complete(self._loop.shutdown_asyncgens())
1233
+ except (RuntimeError, asyncio.CancelledError):
1234
+ # Expected during forced shutdown - async generators may already be running
1235
+ pass
1236
+
1237
+ self._loop.close()
1238
+ asyncio.set_event_loop(None)
1239
+ sys.unraisablehook = original_hook
1115
1240
 
1116
1241
  async def _run_manual_compact(self, custom_instructions: str) -> None:
1117
1242
  """Manual compaction: clear bulky tool output and summarize conversation."""
@@ -1135,10 +1260,12 @@ class RichUI:
1135
1260
  summary_text = await self._summarize_conversation(
1136
1261
  messages_for_summary, custom_instructions
1137
1262
  )
1138
- except Exception as e:
1263
+ except (OSError, RuntimeError, ConnectionError, ValueError, KeyError) as e:
1139
1264
  console.print(f"[red]Error during compaction: {escape(str(e))}[/red]")
1140
- logger.exception(
1141
- "[ui] Error during manual compaction", extra={"session_id": self.session_id}
1265
+ logger.warning(
1266
+ "[ui] Error during manual compaction: %s: %s",
1267
+ type(e).__name__, e,
1268
+ extra={"session_id": self.session_id},
1142
1269
  )
1143
1270
  return
1144
1271
  finally: