ripperdoc 0.2.4__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 (75) 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 +33 -13
  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 +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {ripperdoc-0.2.4.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,
@@ -46,6 +49,7 @@ from ripperdoc.utils.message_compaction import (
46
49
  )
47
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):
@@ -450,12 +470,14 @@ class RichUI:
450
470
  failed = failed or (success is False)
451
471
  failed = failed or bool(self._get_tool_field(tool_data, "is_error"))
452
472
 
473
+ # Extract warning/token info
453
474
  warning_text = None
454
475
  token_estimate = None
455
476
  if tool_data is not None:
456
477
  warning_text = self._get_tool_field(tool_data, "warning")
457
478
  token_estimate = self._get_tool_field(tool_data, "token_estimate")
458
479
 
480
+ # Handle failure case
459
481
  if failed:
460
482
  if content:
461
483
  self.console.print(f" ⎿ [red]{escape(content)}[/red]")
@@ -463,6 +485,7 @@ class RichUI:
463
485
  self.console.print(f" ⎿ [red]{escape(sender)} failed[/red]")
464
486
  return
465
487
 
488
+ # Display warnings and token estimates
466
489
  if warning_text:
467
490
  self.console.print(f" ⎿ [yellow]{escape(str(warning_text))}[/yellow]")
468
491
  if token_estimate:
@@ -470,172 +493,21 @@ class RichUI:
470
493
  f" [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
471
494
  )
472
495
  elif token_estimate and self.verbose:
473
- self.console.print(
474
- f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
475
- )
496
+ self.console.print(f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]")
476
497
 
498
+ # Handle empty content
477
499
  if not content:
478
500
  self.console.print(" ⎿ [dim]Tool completed[/]")
479
501
  return
480
502
 
481
- if "Todo" in sender:
482
- lines = content.splitlines()
483
- if lines:
484
- self.console.print(f" ⎿ [dim]{escape(lines[0])}[/]")
485
- for line in lines[1:]:
486
- self.console.print(f" {line}", markup=False)
487
- else:
488
- self.console.print(" ⎿ [dim]Todo update[/]")
489
- return
490
-
491
- if "Read" in sender or "View" in sender:
492
- lines = content.split("\n")
493
- line_count = len(lines)
494
- self.console.print(f" ⎿ [dim]Read {line_count} lines[/]")
495
- if self.verbose:
496
- preview = lines[:30]
497
- for line in preview:
498
- self.console.print(line, markup=False)
499
- if len(lines) > len(preview):
500
- self.console.print(f"[dim]... ({len(lines) - len(preview)} more lines)[/]")
501
- return
502
-
503
- if "Write" in sender or "Edit" in sender or "MultiEdit" in sender:
504
- if tool_data and (hasattr(tool_data, "file_path") or isinstance(tool_data, dict)):
505
- file_path = self._get_tool_field(tool_data, "file_path")
506
- additions = self._get_tool_field(tool_data, "additions", 0)
507
- deletions = self._get_tool_field(tool_data, "deletions", 0)
508
- diff_with_line_numbers = self._get_tool_field(
509
- tool_data, "diff_with_line_numbers", []
510
- )
511
-
512
- if not file_path:
513
- self.console.print(" ⎿ [dim]File updated successfully[/]")
514
- return
515
-
516
- self.console.print(
517
- f" ⎿ [dim]Updated {escape(str(file_path))} with {additions} additions and {deletions} removals[/]"
518
- )
519
-
520
- if self.verbose:
521
- for line in diff_with_line_numbers:
522
- self.console.print(line, markup=False)
523
- else:
524
- self.console.print(" ⎿ [dim]File updated successfully[/]")
525
- return
526
-
527
- if "Glob" in sender:
528
- files = content.split("\n")
529
- file_count = len([f for f in files if f.strip()])
530
- self.console.print(f" ⎿ [dim]Found {file_count} files[/]")
531
- if self.verbose:
532
- for line in files[:30]:
533
- if line.strip():
534
- self.console.print(f" {line}", markup=False)
535
- if file_count > 30:
536
- self.console.print(f"[dim]... ({file_count - 30} more)[/]")
537
- return
538
-
539
- if "Grep" in sender:
540
- matches = content.split("\n")
541
- match_count = len([m for m in matches if m.strip()])
542
- self.console.print(f" ⎿ [dim]Found {match_count} matches[/]")
543
- if self.verbose:
544
- for line in matches[:30]:
545
- if line.strip():
546
- self.console.print(f" {line}", markup=False)
547
- if match_count > 30:
548
- self.console.print(f"[dim]... ({match_count - 30} more)[/]")
549
- return
550
-
551
- if "LS" in sender:
552
- tree_lines = content.splitlines()
553
- self.console.print(f" ⎿ [dim]Directory tree ({len(tree_lines)} lines)[/]")
554
- if self.verbose:
555
- preview = tree_lines[:40]
556
- for line in preview:
557
- self.console.print(f" {line}", markup=False)
558
- if len(tree_lines) > len(preview):
559
- self.console.print(f"[dim]... ({len(tree_lines) - len(preview)} more)[/]")
560
- return
561
-
562
- if "Bash" in sender:
563
- stdout = ""
564
- stderr = ""
565
- stdout_lines: List[str] = []
566
- stderr_lines: List[str] = []
567
-
568
- exit_code = 0
569
- duration_ms = 0
570
- timeout_ms = 0
571
-
572
- if tool_data:
573
- exit_code = self._get_tool_field(tool_data, "exit_code", 0)
574
- stdout = self._get_tool_field(tool_data, "stdout", "") or ""
575
- stderr = self._get_tool_field(tool_data, "stderr", "") or ""
576
- duration_ms = self._get_tool_field(tool_data, "duration_ms", 0) or 0
577
- timeout_ms = self._get_tool_field(tool_data, "timeout_ms", 0) or 0
578
- stdout_lines = stdout.splitlines() if stdout else []
579
- stderr_lines = stderr.splitlines() if stderr else []
580
-
581
- if not stdout_lines and not stderr_lines and content:
582
- fallback_stdout, fallback_stderr = self._parse_bash_output_sections(content)
583
- stdout_lines = fallback_stdout
584
- stderr_lines = fallback_stderr
585
-
586
- show_inline_stdout = (
587
- stdout_lines and not stderr_lines and exit_code == 0 and not self.verbose
588
- )
589
-
590
- if show_inline_stdout:
591
- preview = stdout_lines if self.verbose else stdout_lines[:5]
592
- self.console.print(f" ⎿ {preview[0]}", markup=False)
593
- for line in preview[1:]:
594
- self.console.print(f" {line}", markup=False)
595
- if not self.verbose and len(stdout_lines) > len(preview):
596
- self.console.print(
597
- f"[dim]... ({len(stdout_lines) - len(preview)} more lines)[/]"
598
- )
599
- else:
600
- if tool_data:
601
- timing = ""
602
- if duration_ms:
603
- timing = f" ({duration_ms / 1000:.2f}s"
604
- if timeout_ms:
605
- timing += f" / timeout {timeout_ms / 1000:.0f}s"
606
- timing += ")"
607
- elif timeout_ms:
608
- timing = f" (timeout {timeout_ms / 1000:.0f}s)"
609
- self.console.print(f" ⎿ [dim]Exit code {exit_code}{timing}[/]")
610
- else:
611
- self.console.print(" ⎿ [dim]Command executed[/]")
612
-
613
- if stdout_lines:
614
- preview = stdout_lines if self.verbose else stdout_lines[:5]
615
- self.console.print("[dim]stdout:[/]")
616
- for line in preview:
617
- self.console.print(f" {line}", markup=False)
618
- if not self.verbose and len(stdout_lines) > len(preview):
619
- self.console.print(
620
- f"[dim]... ({len(stdout_lines) - len(preview)} more stdout lines)[/]"
621
- )
622
- else:
623
- self.console.print("[dim]stdout:[/]")
624
- self.console.print(" [dim](no stdout)[/]")
625
- if stderr_lines:
626
- preview = stderr_lines if self.verbose else stderr_lines[:5]
627
- self.console.print("[dim]stderr:[/]")
628
- for line in preview:
629
- self.console.print(f" {line}", markup=False)
630
- if not self.verbose and len(stderr_lines) > len(preview):
631
- self.console.print(
632
- f"[dim]... ({len(stderr_lines) - len(preview)} more stderr lines)[/]"
633
- )
634
- else:
635
- self.console.print("[dim]stderr:[/]")
636
- 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):
637
508
  return
638
509
 
510
+ # Fallback for unhandled tools
639
511
  self.console.print(" ⎿ [dim]Tool completed[/]")
640
512
 
641
513
  def _print_generic_tool(self, sender: str, content: str) -> None:
@@ -701,13 +573,40 @@ class RichUI:
701
573
  parts: List[str] = []
702
574
  for block in content:
703
575
  text = getattr(block, "text", None)
576
+ if text is None:
577
+ text = getattr(block, "thinking", None)
704
578
  if not text and isinstance(block, dict):
705
- text = block.get("text")
579
+ text = block.get("text") or block.get("thinking") or block.get("data")
706
580
  if text:
707
581
  parts.append(str(text))
708
582
  return "\n".join(parts)
709
583
  return ""
710
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
+
711
610
  def _render_transcript(self, messages: List[ConversationMessage]) -> str:
712
611
  """Render a simple transcript for summarization."""
713
612
  lines: List[str] = []
@@ -735,14 +634,256 @@ class RichUI:
735
634
  return "\n".join(parts)
736
635
  return ""
737
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
+
738
879
  async def process_query(self, user_input: str) -> None:
739
880
  """Process a user query and display the response."""
881
+ # Initialize or reset query context
740
882
  if not self.query_context:
741
883
  self.query_context = QueryContext(
742
884
  tools=self.get_default_tools(), safe_mode=self.safe_mode, verbose=self.verbose
743
885
  )
744
886
  else:
745
- # Clear any prior abort so new queries aren't immediately interrupted.
746
887
  abort_controller = getattr(self.query_context, "abort_controller", None)
747
888
  if abort_controller is not None:
748
889
  abort_controller.clear()
@@ -757,42 +898,16 @@ class RichUI:
757
898
  )
758
899
 
759
900
  try:
760
- context: Dict[str, str] = {}
761
- servers = await load_mcp_servers_async(self.project_path)
762
- dynamic_tools = await load_dynamic_mcp_tools_async(self.project_path)
763
- if dynamic_tools:
764
- self.query_context.tools = merge_tools_with_dynamic(
765
- self.query_context.tools, dynamic_tools
766
- )
767
- logger.debug(
768
- "[ui] Prepared tools and MCP servers",
769
- extra={
770
- "session_id": self.session_id,
771
- "tool_count": len(self.query_context.tools),
772
- "mcp_servers": len(servers),
773
- "dynamic_tools": len(dynamic_tools),
774
- },
775
- )
776
- mcp_instructions = format_mcp_instructions(servers)
777
- base_system_prompt = build_system_prompt(
778
- self.query_context.tools,
779
- user_input,
780
- context,
781
- mcp_instructions=mcp_instructions,
782
- )
783
- memory_instructions = build_memory_instructions()
784
- system_prompt = (
785
- f"{base_system_prompt}\n\n{memory_instructions}"
786
- if memory_instructions
787
- else base_system_prompt
788
- )
901
+ # Prepare context and system prompt
902
+ system_prompt, context = await self._prepare_query_context(user_input)
789
903
 
790
- # Create user message
904
+ # Create and log user message
791
905
  user_message = create_user_message(user_input)
792
- messages = self.conversation_messages + [user_message]
906
+ messages: List[ConversationMessage] = self.conversation_messages + [user_message]
793
907
  self._log_message(user_message)
794
908
  self._append_prompt_history(user_input)
795
909
 
910
+ # Get model configuration
796
911
  config = get_global_config()
797
912
  model_profile = get_profile_for_pointer("main")
798
913
  max_context_tokens = get_remaining_context_tokens(
@@ -801,89 +916,59 @@ class RichUI:
801
916
  auto_compact_enabled = resolve_auto_compact_enabled(config)
802
917
  protocol = provider_protocol(model_profile.provider) if model_profile else "openai"
803
918
 
804
- used_tokens = estimate_used_tokens(messages, protocol=protocol) # type: ignore[arg-type]
805
- usage_status = get_context_usage_status(
806
- used_tokens, max_context_tokens, auto_compact_enabled
807
- )
808
- logger.debug(
809
- "[ui] Context usage snapshot",
810
- extra={
811
- "session_id": self.session_id,
812
- "used_tokens": used_tokens,
813
- "max_context_tokens": max_context_tokens,
814
- "percent_used": round(usage_status.percent_used, 2),
815
- "auto_compact_enabled": auto_compact_enabled,
816
- },
919
+ # Check and potentially compact messages
920
+ messages = self._check_and_compact_messages(
921
+ messages, max_context_tokens, auto_compact_enabled, protocol
817
922
  )
818
923
 
819
- if usage_status.is_above_warning:
820
- console.print(
821
- f"[yellow]Context usage is {usage_status.percent_used:.1f}% "
822
- f"({usage_status.total_tokens}/{usage_status.max_context_tokens} tokens).[/yellow]"
823
- )
824
- if not auto_compact_enabled:
825
- console.print(
826
- "[dim]Auto-compaction is disabled; run /compact to trim history.[/dim]"
827
- )
828
-
829
- if usage_status.should_auto_compact:
830
- original_messages = list(messages)
831
- compaction = compact_messages(messages, protocol=protocol) # type: ignore[arg-type]
832
- if compaction.was_compacted:
833
- if self._saved_conversation is None:
834
- self._saved_conversation = original_messages # type: ignore[assignment]
835
- messages = compaction.messages # type: ignore[assignment]
836
- console.print(
837
- f"[yellow]Auto-compacted conversation (saved ~{compaction.tokens_saved} tokens). "
838
- f"Estimated usage: {compaction.tokens_after}/{max_context_tokens} tokens.[/yellow]"
839
- )
840
- logger.info(
841
- "[ui] Auto-compacted conversation",
842
- extra={
843
- "session_id": self.session_id,
844
- "tokens_before": compaction.tokens_before,
845
- "tokens_after": compaction.tokens_after,
846
- "tokens_saved": compaction.tokens_saved,
847
- "cleared_tool_ids": list(compaction.cleared_tool_ids),
848
- },
849
- )
850
-
924
+ # Setup spinner and callbacks
851
925
  prompt_tokens_est = estimate_conversation_tokens(messages, protocol=protocol)
852
926
  spinner = ThinkingSpinner(console, prompt_tokens_est)
853
927
 
854
- # Define pause/resume callbacks for tools that need user interaction
855
928
  def pause_ui() -> None:
856
- if spinner:
857
- spinner.stop()
929
+ spinner.stop()
858
930
 
859
931
  def resume_ui() -> None:
860
- if spinner:
861
- spinner.start()
862
- spinner.update("Thinking...")
932
+ spinner.start()
933
+ spinner.update("Thinking...")
863
934
 
864
- # Set the UI callbacks on the query context
865
935
  self.query_context.pause_ui = pause_ui
866
936
  self.query_context.resume_ui = resume_ui
867
937
 
868
- # Wrap permission checker to pause the spinner while waiting for user input.
938
+ # Create permission checker with spinner control
869
939
  base_permission_checker = self._permission_checker
870
940
 
871
941
  async def permission_checker(tool: Any, parsed_input: Any) -> bool:
872
- if spinner:
873
- spinner.stop()
942
+ spinner.stop()
874
943
  try:
875
944
  if base_permission_checker is not None:
876
945
  result = await base_permission_checker(tool, parsed_input)
877
- 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
878
956
  return True
879
957
  finally:
880
- if spinner:
958
+ # Wrap spinner restart in try-except to prevent exceptions
959
+ # from discarding the permission result
960
+ try:
881
961
  spinner.start()
882
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
+ )
883
968
 
884
- # Track tool uses by ID so results align even when multiple tools fire.
969
+ # Process query stream
885
970
  tool_registry: Dict[str, Dict[str, Any]] = {}
886
- last_tool_name = None
971
+ last_tool_name: Optional[str] = None
887
972
  output_token_est = 0
888
973
 
889
974
  try:
@@ -896,117 +981,41 @@ class RichUI:
896
981
  permission_checker, # type: ignore[arg-type]
897
982
  ):
898
983
  if message.type == "assistant" and isinstance(message, AssistantMessage):
899
- # Extract text content from assistant message
900
- if isinstance(message.message.content, str):
901
- self.display_message("Ripperdoc", message.message.content)
902
- elif isinstance(message.message.content, list):
903
- for block in message.message.content:
904
- if hasattr(block, "type") and block.type == "text" and block.text:
905
- self.display_message("Ripperdoc", block.text)
906
- elif hasattr(block, "type") and block.type == "tool_use":
907
- # Show tool usage in the new format
908
- tool_name = getattr(block, "name", "unknown tool")
909
- tool_args = getattr(block, "input", {})
910
-
911
- tool_use_id = getattr(block, "tool_use_id", None) or getattr(
912
- block, "id", None
913
- )
914
- if tool_use_id:
915
- tool_registry[tool_use_id] = {
916
- "name": tool_name,
917
- "args": tool_args,
918
- "printed": False,
919
- }
920
- if tool_name == "Task":
921
- self.display_message(
922
- tool_name,
923
- "",
924
- is_tool=True,
925
- tool_type="call",
926
- tool_args=tool_args,
927
- )
928
- if tool_use_id:
929
- tool_registry[tool_use_id]["printed"] = True
930
- last_tool_name = tool_name
984
+ result = self._handle_assistant_message(message, tool_registry)
985
+ if result:
986
+ last_tool_name = result
931
987
 
932
988
  elif message.type == "user" and isinstance(message, UserMessage):
933
- # Handle tool results - show summary instead of full content
934
- if isinstance(message.message.content, list):
935
- for block in message.message.content:
936
- if (
937
- hasattr(block, "type")
938
- and block.type == "tool_result"
939
- and block.text
940
- ):
941
- tool_name = "Tool"
942
- tool_data = getattr(message, "tool_use_result", None)
943
- is_error = bool(getattr(block, "is_error", False))
944
-
945
- tool_use_id = getattr(block, "tool_use_id", None)
946
- entry = tool_registry.get(tool_use_id) if tool_use_id else None
947
- if entry:
948
- tool_name = entry.get("name", tool_name)
949
- if not entry.get("printed"):
950
- self.display_message(
951
- tool_name,
952
- "",
953
- is_tool=True,
954
- tool_type="call",
955
- tool_args=entry.get("args", {}),
956
- )
957
- entry["printed"] = True
958
- elif last_tool_name:
959
- tool_name = last_tool_name
960
-
961
- self.display_message(
962
- tool_name,
963
- block.text,
964
- is_tool=True,
965
- tool_type="result",
966
- tool_data=tool_data,
967
- tool_error=is_error,
968
- )
989
+ self._handle_tool_result_message(message, tool_registry, last_tool_name)
969
990
 
970
991
  elif message.type == "progress" and isinstance(message, ProgressMessage):
971
- if self.verbose:
972
- self.display_message(
973
- "System", f"Progress: {message.content}", is_tool=True
974
- )
975
- elif message.content and isinstance(message.content, str):
976
- if message.content.startswith("Subagent: "):
977
- self.display_message(
978
- "Subagent", message.content[len("Subagent: ") :], is_tool=True
979
- )
980
- elif message.content.startswith("Subagent"):
981
- self.display_message("Subagent", message.content, is_tool=True)
982
- if message.tool_use_id == "stream":
983
- delta_tokens = estimate_tokens(message.content)
984
- output_token_est += delta_tokens
985
- spinner.update_tokens(output_token_est)
986
- else:
987
- spinner.update_tokens(
988
- output_token_est, suffix=f"Working... {message.content}"
989
- )
992
+ output_token_est = self._handle_progress_message(
993
+ message, spinner, output_token_est
994
+ )
990
995
 
991
- # Add message to history
992
996
  self._log_message(message)
993
997
  messages.append(message) # type: ignore[arg-type]
994
- except Exception as e:
995
- logger.exception(
996
- "[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,
997
1006
  extra={"session_id": self.session_id},
998
1007
  )
999
1008
  self.display_message("System", f"Error: {str(e)}", is_tool=True)
1000
1009
  finally:
1001
- # Ensure spinner stops even on exceptions
1002
1010
  try:
1003
1011
  spinner.stop()
1004
- except Exception:
1005
- logger.exception(
1006
- "[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},
1007
1017
  )
1008
1018
 
1009
- # Update conversation history
1010
1019
  self.conversation_messages = messages
1011
1020
  logger.info(
1012
1021
  "[ui] Query processing completed",
@@ -1016,9 +1025,28 @@ class RichUI:
1016
1025
  "project_path": str(self.project_path),
1017
1026
  },
1018
1027
  )
1019
- finally:
1020
- await shutdown_mcp_runtime()
1021
- 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)
1022
1050
 
1023
1051
  def handle_slash_command(self, user_input: str) -> bool:
1024
1052
  """Handle slash commands. Returns True if the input was handled."""
@@ -1091,60 +1119,124 @@ class RichUI:
1091
1119
  extra={"session_id": self.session_id, "log_file": str(self.log_file_path)},
1092
1120
  )
1093
1121
 
1094
- while not self._should_exit:
1095
- try:
1096
- # Get user input
1097
- user_input = session.prompt("> ")
1122
+ try:
1123
+ while not self._should_exit:
1124
+ try:
1125
+ # Get user input
1126
+ user_input = session.prompt("> ")
1098
1127
 
1099
- if not user_input.strip():
1100
- continue
1128
+ if not user_input.strip():
1129
+ continue
1101
1130
 
1102
- if user_input.strip() == "?":
1103
- self._print_shortcuts()
1104
- console.print()
1105
- continue
1131
+ if user_input.strip() == "?":
1132
+ self._print_shortcuts()
1133
+ console.print()
1134
+ continue
1106
1135
 
1107
- # Handle slash commands locally
1108
- if user_input.startswith("/"):
1109
- logger.debug(
1110
- "[ui] Received slash command",
1111
- 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
+ },
1112
1157
  )
1113
- handled = self.handle_slash_command(user_input)
1114
- if self._should_exit:
1115
- break
1116
- if handled:
1117
- console.print() # spacing
1118
- 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
1119
1182
 
1120
- # Process the query
1121
- logger.info(
1122
- "[ui] Processing interactive prompt",
1123
- extra={
1124
- "session_id": self.session_id,
1125
- "prompt_length": len(user_input),
1126
- "prompt_preview": user_input[:200],
1127
- },
1128
- )
1129
- 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()
1130
1190
 
1131
- console.print() # Add spacing between interactions
1191
+ # Suppress async generator cleanup errors during shutdown
1192
+ original_hook = sys.unraisablehook
1132
1193
 
1133
- except KeyboardInterrupt:
1134
- console.print("\n[yellow]Goodbye![/yellow]")
1135
- break
1136
- except EOFError:
1137
- console.print("\n[yellow]Goodbye![/yellow]")
1138
- break
1139
- except Exception as e:
1140
- console.print(f"[red]Error: {escape(str(e))}[/]")
1141
- logger.exception(
1142
- "[ui] Error in interactive loop", extra={"session_id": self.session_id}
1143
- )
1144
- if self.verbose:
1145
- 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
1146
1229
 
1147
- 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
1148
1240
 
1149
1241
  async def _run_manual_compact(self, custom_instructions: str) -> None:
1150
1242
  """Manual compaction: clear bulky tool output and summarize conversation."""
@@ -1168,10 +1260,12 @@ class RichUI:
1168
1260
  summary_text = await self._summarize_conversation(
1169
1261
  messages_for_summary, custom_instructions
1170
1262
  )
1171
- except Exception as e:
1263
+ except (OSError, RuntimeError, ConnectionError, ValueError, KeyError) as e:
1172
1264
  console.print(f"[red]Error during compaction: {escape(str(e))}[/red]")
1173
- logger.exception(
1174
- "[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},
1175
1269
  )
1176
1270
  return
1177
1271
  finally: