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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/__main__.py +0 -5
- ripperdoc/cli/cli.py +37 -16
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +12 -9
- ripperdoc/cli/commands/compact_cmd.py +7 -3
- ripperdoc/cli/commands/context_cmd.py +33 -13
- ripperdoc/cli/commands/doctor_cmd.py +27 -14
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/mcp_cmd.py +13 -8
- ripperdoc/cli/commands/memory_cmd.py +5 -5
- ripperdoc/cli/commands/models_cmd.py +47 -16
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +1 -2
- ripperdoc/cli/commands/tasks_cmd.py +24 -13
- ripperdoc/cli/ui/rich_ui.py +500 -406
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +17 -9
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +7 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/anthropic.py +107 -4
- ripperdoc/core/providers/base.py +33 -4
- ripperdoc/core/providers/gemini.py +169 -50
- ripperdoc/core/providers/openai.py +257 -23
- ripperdoc/core/query.py +294 -61
- ripperdoc/core/query_utils.py +50 -6
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +13 -7
- ripperdoc/core/tool.py +8 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +20 -22
- ripperdoc/tools/background_shell.py +19 -13
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +5 -2
- ripperdoc/tools/exit_plan_mode_tool.py +6 -3
- ripperdoc/tools/file_edit_tool.py +53 -10
- ripperdoc/tools/file_read_tool.py +17 -7
- ripperdoc/tools/file_write_tool.py +49 -13
- ripperdoc/tools/glob_tool.py +10 -9
- ripperdoc/tools/grep_tool.py +182 -51
- ripperdoc/tools/ls_tool.py +6 -6
- ripperdoc/tools/mcp_tools.py +106 -456
- ripperdoc/tools/multi_edit_tool.py +49 -9
- ripperdoc/tools/notebook_edit_tool.py +57 -13
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +7 -8
- ripperdoc/tools/todo_tool.py +12 -12
- ripperdoc/tools/tool_search_tool.py +5 -6
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/file_watch.py +5 -4
- ripperdoc/utils/json_utils.py +4 -4
- ripperdoc/utils/log.py +3 -3
- ripperdoc/utils/mcp.py +36 -15
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +16 -11
- ripperdoc/utils/messages.py +73 -8
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/permissions/__init__.py +7 -1
- ripperdoc/utils/permissions/path_validation_utils.py +5 -3
- ripperdoc/utils/permissions/shell_command_validation.py +496 -18
- ripperdoc/utils/prompt.py +1 -1
- ripperdoc/utils/safe_get_cwd.py +5 -2
- ripperdoc/utils/session_history.py +38 -19
- ripperdoc/utils/todo.py +6 -2
- ripperdoc/utils/token_estimation.py +4 -3
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.4.dist-info/RECORD +0 -99
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -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
|
|
287
|
+
except (OSError, IOError, json.JSONDecodeError) as exc:
|
|
271
288
|
# Logging failures should never interrupt the UI flow
|
|
272
|
-
logger.
|
|
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
|
|
285
|
-
logger.
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
|
761
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
857
|
-
spinner.stop()
|
|
929
|
+
spinner.stop()
|
|
858
930
|
|
|
859
931
|
def resume_ui() -> None:
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
900
|
-
if
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
|
1005
|
-
logger.
|
|
1006
|
-
"[ui] Failed to stop spinner
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1122
|
+
try:
|
|
1123
|
+
while not self._should_exit:
|
|
1124
|
+
try:
|
|
1125
|
+
# Get user input
|
|
1126
|
+
user_input = session.prompt("> ")
|
|
1098
1127
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1128
|
+
if not user_input.strip():
|
|
1129
|
+
continue
|
|
1101
1130
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1131
|
+
if user_input.strip() == "?":
|
|
1132
|
+
self._print_shortcuts()
|
|
1133
|
+
console.print()
|
|
1134
|
+
continue
|
|
1106
1135
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1191
|
+
# Suppress async generator cleanup errors during shutdown
|
|
1192
|
+
original_hook = sys.unraisablehook
|
|
1132
1193
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
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
|
|
1263
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, KeyError) as e:
|
|
1172
1264
|
console.print(f"[red]Error during compaction: {escape(str(e))}[/red]")
|
|
1173
|
-
logger.
|
|
1174
|
-
"[ui] Error during manual compaction
|
|
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:
|