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