Fix project isolation: Make loadChatHistory respect active project sessions
- Modified loadChatHistory() to check for active project before fetching all sessions - When active project exists, use project.sessions instead of fetching from API - Added detailed console logging to debug session filtering - This prevents ALL sessions from appearing in every project's sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
# ABOUTME: Output formatter module initialization
|
||||
# ABOUTME: Exports base classes, formatter implementations, and legacy console classes
|
||||
|
||||
"""Output formatting module for Claude adapter responses.
|
||||
|
||||
This module provides:
|
||||
|
||||
1. Legacy output utilities (backward compatible):
|
||||
- DiffStats, DiffFormatter, RalphConsole - Rich terminal utilities
|
||||
- RICH_AVAILABLE - Rich library availability flag
|
||||
|
||||
2. New formatter classes for structured output:
|
||||
- PlainTextFormatter: Basic text output without colors
|
||||
- RichTerminalFormatter: Rich terminal output with colors and panels
|
||||
- JsonFormatter: Structured JSON output for programmatic consumption
|
||||
|
||||
Example usage (new formatters):
|
||||
from ralph_orchestrator.output import (
|
||||
RichTerminalFormatter,
|
||||
VerbosityLevel,
|
||||
ToolCallInfo,
|
||||
)
|
||||
|
||||
formatter = RichTerminalFormatter(verbosity=VerbosityLevel.VERBOSE)
|
||||
tool_info = ToolCallInfo(tool_name="Read", tool_id="abc123", input_params={"path": "test.py"})
|
||||
output = formatter.format_tool_call(tool_info, iteration=1)
|
||||
formatter.print(output)
|
||||
|
||||
Example usage (legacy console):
|
||||
from ralph_orchestrator.output import RalphConsole
|
||||
|
||||
console = RalphConsole()
|
||||
console.print_status("Processing...")
|
||||
console.print_success("Done!")
|
||||
"""
|
||||
|
||||
# Import legacy classes from console module (backward compatibility)
|
||||
from .console import (
|
||||
RICH_AVAILABLE,
|
||||
DiffFormatter,
|
||||
DiffStats,
|
||||
RalphConsole,
|
||||
)
|
||||
|
||||
# Import new formatter base classes
|
||||
from .base import (
|
||||
FormatContext,
|
||||
MessageType,
|
||||
OutputFormatter,
|
||||
TokenUsage,
|
||||
ToolCallInfo,
|
||||
VerbosityLevel,
|
||||
)
|
||||
|
||||
# Import content detection
|
||||
from .content_detector import ContentDetector, ContentType
|
||||
|
||||
# Import new formatter implementations
|
||||
from .json_formatter import JsonFormatter
|
||||
from .plain import PlainTextFormatter
|
||||
from .rich_formatter import RichTerminalFormatter
|
||||
|
||||
__all__ = [
|
||||
# Legacy exports (backward compatibility)
|
||||
"RICH_AVAILABLE",
|
||||
"DiffStats",
|
||||
"DiffFormatter",
|
||||
"RalphConsole",
|
||||
# New base classes
|
||||
"OutputFormatter",
|
||||
"VerbosityLevel",
|
||||
"MessageType",
|
||||
"TokenUsage",
|
||||
"ToolCallInfo",
|
||||
"FormatContext",
|
||||
# Content detection
|
||||
"ContentDetector",
|
||||
"ContentType",
|
||||
# New formatters
|
||||
"PlainTextFormatter",
|
||||
"RichTerminalFormatter",
|
||||
"JsonFormatter",
|
||||
# Factory function
|
||||
"create_formatter",
|
||||
]
|
||||
|
||||
|
||||
def create_formatter(
|
||||
format_type: str = "rich",
|
||||
verbosity: VerbosityLevel = VerbosityLevel.NORMAL,
|
||||
**kwargs,
|
||||
) -> OutputFormatter:
|
||||
"""Factory function to create appropriate formatter.
|
||||
|
||||
Args:
|
||||
format_type: Type of formatter ("plain", "rich", "json")
|
||||
verbosity: Verbosity level for output
|
||||
**kwargs: Additional arguments passed to formatter constructor
|
||||
|
||||
Returns:
|
||||
Configured OutputFormatter instance
|
||||
|
||||
Raises:
|
||||
ValueError: If format_type is not recognized
|
||||
"""
|
||||
formatters = {
|
||||
"plain": PlainTextFormatter,
|
||||
"text": PlainTextFormatter,
|
||||
"rich": RichTerminalFormatter,
|
||||
"terminal": RichTerminalFormatter,
|
||||
"json": JsonFormatter,
|
||||
}
|
||||
|
||||
if format_type.lower() not in formatters:
|
||||
raise ValueError(
|
||||
f"Unknown format type: {format_type}. "
|
||||
f"Valid options: {', '.join(formatters.keys())}"
|
||||
)
|
||||
|
||||
formatter_class = formatters[format_type.lower()]
|
||||
return formatter_class(verbosity=verbosity, **kwargs)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,404 @@
|
||||
# ABOUTME: Base classes and interfaces for output formatting
|
||||
# ABOUTME: Defines OutputFormatter ABC with verbosity levels, event types, and token tracking
|
||||
|
||||
"""Base classes for Claude adapter output formatting."""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VerbosityLevel(Enum):
|
||||
"""Verbosity levels for output formatting."""
|
||||
|
||||
QUIET = 0 # Only errors and final results
|
||||
NORMAL = 1 # Tool calls, assistant messages (no details)
|
||||
VERBOSE = 2 # Full tool inputs/outputs, detailed messages
|
||||
DEBUG = 3 # Everything including internal state
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Types of messages that can be formatted."""
|
||||
|
||||
SYSTEM = "system"
|
||||
ASSISTANT = "assistant"
|
||||
USER = "user"
|
||||
TOOL_CALL = "tool_call"
|
||||
TOOL_RESULT = "tool_result"
|
||||
ERROR = "error"
|
||||
INFO = "info"
|
||||
PROGRESS = "progress"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
"""Tracks token usage and costs."""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
cost: float = 0.0
|
||||
model: str = ""
|
||||
|
||||
# Running totals across session
|
||||
session_input_tokens: int = 0
|
||||
session_output_tokens: int = 0
|
||||
session_total_tokens: int = 0
|
||||
session_cost: float = 0.0
|
||||
|
||||
def add(
|
||||
self,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
cost: float = 0.0,
|
||||
model: str = "",
|
||||
) -> None:
|
||||
"""Add tokens to current and session totals."""
|
||||
self.input_tokens = input_tokens
|
||||
self.output_tokens = output_tokens
|
||||
self.total_tokens = input_tokens + output_tokens
|
||||
self.cost = cost
|
||||
if model:
|
||||
self.model = model
|
||||
|
||||
self.session_input_tokens += input_tokens
|
||||
self.session_output_tokens += output_tokens
|
||||
self.session_total_tokens += input_tokens + output_tokens
|
||||
self.session_cost += cost
|
||||
|
||||
def reset_current(self) -> None:
|
||||
"""Reset current iteration tokens (keep session totals)."""
|
||||
self.input_tokens = 0
|
||||
self.output_tokens = 0
|
||||
self.total_tokens = 0
|
||||
self.cost = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallInfo:
|
||||
"""Information about a tool call."""
|
||||
|
||||
tool_name: str
|
||||
tool_id: str
|
||||
input_params: Dict[str, Any] = field(default_factory=dict)
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
result: Optional[Any] = None
|
||||
is_error: bool = False
|
||||
duration_ms: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormatContext:
|
||||
"""Context information for formatting operations."""
|
||||
|
||||
iteration: int = 0
|
||||
verbosity: VerbosityLevel = VerbosityLevel.NORMAL
|
||||
timestamp: Optional[datetime] = None
|
||||
token_usage: Optional[TokenUsage] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.timestamp is None:
|
||||
self.timestamp = datetime.now()
|
||||
if self.token_usage is None:
|
||||
self.token_usage = TokenUsage()
|
||||
|
||||
|
||||
class OutputFormatter(ABC):
|
||||
"""Abstract base class for output formatters.
|
||||
|
||||
Formatters handle rendering of Claude adapter events to different output
|
||||
formats (plain text, rich terminal, JSON, etc.). They support verbosity
|
||||
levels and consistent token usage tracking.
|
||||
"""
|
||||
|
||||
def __init__(self, verbosity: VerbosityLevel = VerbosityLevel.NORMAL) -> None:
|
||||
"""Initialize formatter with verbosity level.
|
||||
|
||||
Args:
|
||||
verbosity: Output verbosity level
|
||||
"""
|
||||
self._verbosity = verbosity
|
||||
self._token_usage = TokenUsage()
|
||||
self._start_time = datetime.now()
|
||||
self._callbacks: List[Callable[[MessageType, Any, FormatContext], None]] = []
|
||||
|
||||
@property
|
||||
def verbosity(self) -> VerbosityLevel:
|
||||
"""Get current verbosity level."""
|
||||
return self._verbosity
|
||||
|
||||
@verbosity.setter
|
||||
def verbosity(self, level: VerbosityLevel) -> None:
|
||||
"""Set verbosity level."""
|
||||
self._verbosity = level
|
||||
|
||||
@property
|
||||
def token_usage(self) -> TokenUsage:
|
||||
"""Get current token usage."""
|
||||
return self._token_usage
|
||||
|
||||
def should_display(self, message_type: MessageType) -> bool:
|
||||
"""Check if message type should be displayed at current verbosity.
|
||||
|
||||
Args:
|
||||
message_type: Type of message to check
|
||||
|
||||
Returns:
|
||||
True if message should be displayed
|
||||
"""
|
||||
# Always show errors
|
||||
if message_type == MessageType.ERROR:
|
||||
return True
|
||||
|
||||
if self._verbosity == VerbosityLevel.QUIET:
|
||||
return False
|
||||
|
||||
if self._verbosity == VerbosityLevel.NORMAL:
|
||||
return message_type in (
|
||||
MessageType.ASSISTANT,
|
||||
MessageType.TOOL_CALL,
|
||||
MessageType.PROGRESS,
|
||||
MessageType.INFO,
|
||||
)
|
||||
|
||||
# VERBOSE and DEBUG show everything
|
||||
return True
|
||||
|
||||
def register_callback(
|
||||
self, callback: Callable[[MessageType, Any, FormatContext], None]
|
||||
) -> None:
|
||||
"""Register a callback for format events.
|
||||
|
||||
Args:
|
||||
callback: Function to call with (message_type, content, context)
|
||||
"""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def _notify_callbacks(
|
||||
self, message_type: MessageType, content: Any, context: FormatContext
|
||||
) -> None:
|
||||
"""Notify all registered callbacks."""
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(message_type, content, context)
|
||||
except Exception as e:
|
||||
# Log but don't let callback errors break formatting
|
||||
callback_name = getattr(callback, "__name__", repr(callback))
|
||||
_logger.debug(
|
||||
"Callback %s failed for %s: %s: %s",
|
||||
callback_name,
|
||||
message_type,
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
|
||||
def _create_context(
|
||||
self, iteration: int = 0, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> FormatContext:
|
||||
"""Create a format context with current state.
|
||||
|
||||
Args:
|
||||
iteration: Current iteration number
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
FormatContext instance
|
||||
"""
|
||||
return FormatContext(
|
||||
iteration=iteration,
|
||||
verbosity=self._verbosity,
|
||||
timestamp=datetime.now(),
|
||||
token_usage=self._token_usage,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
def update_tokens(
|
||||
self,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
cost: float = 0.0,
|
||||
model: str = "",
|
||||
) -> None:
|
||||
"""Update token usage tracking.
|
||||
|
||||
Args:
|
||||
input_tokens: Number of input tokens used
|
||||
output_tokens: Number of output tokens used
|
||||
cost: Cost in USD
|
||||
model: Model name
|
||||
"""
|
||||
self._token_usage.add(input_tokens, output_tokens, cost, model)
|
||||
|
||||
@abstractmethod
|
||||
def format_tool_call(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool call for display.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call information
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool result for display.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call info with result
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_assistant_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an assistant message for display.
|
||||
|
||||
Args:
|
||||
message: Assistant message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_system_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a system message for display.
|
||||
|
||||
Args:
|
||||
message: System message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_error(
|
||||
self,
|
||||
error: str,
|
||||
exception: Optional[Exception] = None,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an error for display.
|
||||
|
||||
Args:
|
||||
error: Error message
|
||||
exception: Optional exception object
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_progress(
|
||||
self,
|
||||
message: str,
|
||||
current: int = 0,
|
||||
total: int = 0,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format progress information for display.
|
||||
|
||||
Args:
|
||||
message: Progress message
|
||||
current: Current progress value
|
||||
total: Total progress value
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_token_usage(self, show_session: bool = True) -> str:
|
||||
"""Format token usage summary for display.
|
||||
|
||||
Args:
|
||||
show_session: Include session totals
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_section_header(self, title: str, iteration: int = 0) -> str:
|
||||
"""Format a section header for display.
|
||||
|
||||
Args:
|
||||
title: Section title
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_section_footer(self) -> str:
|
||||
"""Format a section footer for display.
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
pass
|
||||
|
||||
def summarize_content(self, content: str, max_length: int = 500) -> str:
|
||||
"""Summarize long content for display.
|
||||
|
||||
Args:
|
||||
content: Content to summarize
|
||||
max_length: Maximum length before truncation
|
||||
|
||||
Returns:
|
||||
Summarized content
|
||||
"""
|
||||
if len(content) <= max_length:
|
||||
return content
|
||||
|
||||
# Truncate with indicator
|
||||
half = (max_length - 20) // 2
|
||||
return f"{content[:half]}\n... [{len(content)} chars truncated] ...\n{content[-half:]}"
|
||||
|
||||
def get_elapsed_time(self) -> float:
|
||||
"""Get elapsed time since formatter creation.
|
||||
|
||||
Returns:
|
||||
Elapsed time in seconds
|
||||
"""
|
||||
return (datetime.now() - self._start_time).total_seconds()
|
||||
@@ -0,0 +1,915 @@
|
||||
# ABOUTME: Colored terminal output utilities using Rich
|
||||
# ABOUTME: Provides DiffFormatter, DiffStats, and RalphConsole for enhanced CLI output
|
||||
|
||||
"""Colored terminal output utilities using Rich."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import Rich components with fallback
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.markup import escape
|
||||
from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
from rich.table import Table
|
||||
|
||||
RICH_AVAILABLE = True
|
||||
except ImportError:
|
||||
RICH_AVAILABLE = False
|
||||
Console = None # type: ignore
|
||||
Markdown = None # type: ignore
|
||||
Panel = None # type: ignore
|
||||
Syntax = None # type: ignore
|
||||
Table = None # type: ignore
|
||||
|
||||
def escape(x: str) -> str:
|
||||
"""Fallback escape function."""
|
||||
return str(x).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffStats:
|
||||
"""Statistics for diff content."""
|
||||
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
files: int = 0
|
||||
files_changed: dict[str, tuple[int, int]] = field(
|
||||
default_factory=dict
|
||||
) # filename -> (additions, deletions)
|
||||
|
||||
|
||||
class DiffFormatter:
|
||||
"""Formatter for enhanced diff visualization."""
|
||||
|
||||
# Diff display constants
|
||||
MAX_CONTEXT_LINES = 3 # Maximum context lines to show before/after changes
|
||||
LARGE_DIFF_THRESHOLD = 100 # Lines count for "large diff" detection
|
||||
SEPARATOR_WIDTH = 60 # Width of visual separators
|
||||
LINE_NUM_WIDTH = 6 # Width for line number display
|
||||
|
||||
# Binary file patterns
|
||||
BINARY_EXTENSIONS = {
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".pdf",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".so",
|
||||
".pyc",
|
||||
".exe",
|
||||
".dll",
|
||||
}
|
||||
|
||||
def __init__(self, console: "Console") -> None:
|
||||
"""
|
||||
Initialize diff formatter.
|
||||
|
||||
Args:
|
||||
console: Rich console for output
|
||||
"""
|
||||
self.console = console
|
||||
|
||||
def format_and_print(self, text: str) -> None:
|
||||
"""
|
||||
Print diff with enhanced visualization and file path highlighting.
|
||||
|
||||
Features:
|
||||
- Color-coded diff lines (additions, deletions, context)
|
||||
- File path highlighting with improved contrast
|
||||
- Diff statistics summary (+X/-Y lines) with per-file breakdown
|
||||
- Visual separation between file changes with subtle styling
|
||||
- Enhanced hunk headers with line range info and context highlighting
|
||||
- Smart context line limiting for large diffs
|
||||
- Improved spacing for better readability
|
||||
- Binary file detection and special handling
|
||||
- Empty diff detection with clear messaging
|
||||
|
||||
Args:
|
||||
text: Diff text to render
|
||||
"""
|
||||
if not RICH_AVAILABLE:
|
||||
print(text)
|
||||
return
|
||||
|
||||
lines = text.split("\n")
|
||||
|
||||
# Calculate diff statistics
|
||||
stats = self._calculate_stats(lines)
|
||||
|
||||
# Handle empty diffs
|
||||
if stats.additions == 0 and stats.deletions == 0 and stats.files == 0:
|
||||
self.console.print("[dim italic]No changes detected[/dim italic]")
|
||||
return
|
||||
|
||||
# Print summary if we have changes
|
||||
self._print_summary(stats)
|
||||
|
||||
current_file = None
|
||||
current_file_name = None
|
||||
context_line_count = 0
|
||||
in_change_section = False
|
||||
|
||||
for line in lines:
|
||||
# File headers - highlight with bold cyan
|
||||
if line.startswith("diff --git"):
|
||||
# Add visual separator between files (except first)
|
||||
if current_file is not None:
|
||||
# Print per-file stats before separator
|
||||
if current_file_name is not None:
|
||||
self._print_file_stats(current_file_name, stats)
|
||||
self.console.print()
|
||||
self.console.print(f"[dim]{'─' * self.SEPARATOR_WIDTH}[/dim]")
|
||||
self.console.print()
|
||||
|
||||
current_file = line
|
||||
# Extract filename for stats tracking
|
||||
current_file_name = self._extract_filename(line)
|
||||
# Check for binary files
|
||||
if self._is_binary_file(line):
|
||||
self.console.print(
|
||||
f"[bold magenta]{line} [dim](binary)[/dim][/bold magenta]"
|
||||
)
|
||||
else:
|
||||
self.console.print(f"[bold cyan]{line}[/bold cyan]")
|
||||
context_line_count = 0
|
||||
elif line.startswith("Binary files"):
|
||||
# Binary file indicator
|
||||
self.console.print(f"[yellow]📦 {line}[/yellow]")
|
||||
continue
|
||||
elif line.startswith("---") or line.startswith("+++"):
|
||||
# File paths - extract and highlight
|
||||
if line.startswith("---"):
|
||||
self.console.print(f"[bold red]{line}[/bold red]")
|
||||
else: # +++
|
||||
self.console.print(f"[bold green]{line}[/bold green]")
|
||||
# Hunk headers - enhanced with context
|
||||
elif line.startswith("@@"):
|
||||
# Add subtle spacing before hunk for better visual separation
|
||||
if context_line_count > 0:
|
||||
self.console.print()
|
||||
|
||||
# Extract line ranges for better readability
|
||||
hunk_info = self._format_hunk_header(line)
|
||||
self.console.print(f"[bold magenta]{hunk_info}[/bold magenta]")
|
||||
context_line_count = 0
|
||||
in_change_section = False
|
||||
# Added lines - enhanced with bold for better contrast
|
||||
elif line.startswith("+"):
|
||||
self.console.print(f"[bold green]{line}[/bold green]")
|
||||
in_change_section = True
|
||||
context_line_count = 0
|
||||
# Removed lines - enhanced with bold for better contrast
|
||||
elif line.startswith("-"):
|
||||
self.console.print(f"[bold red]{line}[/bold red]")
|
||||
in_change_section = True
|
||||
context_line_count = 0
|
||||
# Context lines
|
||||
else:
|
||||
# Only show limited context lines for large diffs
|
||||
if stats.additions + stats.deletions > self.LARGE_DIFF_THRESHOLD:
|
||||
# Show context around changes only
|
||||
if in_change_section:
|
||||
if context_line_count < self.MAX_CONTEXT_LINES:
|
||||
self.console.print(f"[dim]{line}[/dim]")
|
||||
context_line_count += 1
|
||||
elif context_line_count == self.MAX_CONTEXT_LINES:
|
||||
self.console.print(
|
||||
"[dim italic] ⋮ (context lines omitted for readability)[/dim italic]"
|
||||
)
|
||||
context_line_count += 1
|
||||
else:
|
||||
# Leading context - always show up to limit
|
||||
if context_line_count < self.MAX_CONTEXT_LINES:
|
||||
self.console.print(f"[dim]{line}[/dim]")
|
||||
context_line_count += 1
|
||||
else:
|
||||
# Small diff - show all context
|
||||
self.console.print(f"[dim]{line}[/dim]")
|
||||
|
||||
# Print final file stats
|
||||
if current_file_name:
|
||||
self._print_file_stats(current_file_name, stats)
|
||||
|
||||
# Add spacing after diff for better separation from next content
|
||||
self.console.print()
|
||||
|
||||
def _calculate_stats(self, lines: list[str]) -> DiffStats:
|
||||
"""
|
||||
Calculate statistics from diff lines including per-file breakdown.
|
||||
|
||||
Args:
|
||||
lines: List of diff lines
|
||||
|
||||
Returns:
|
||||
DiffStats with additions, deletions, files count, and per-file breakdown
|
||||
"""
|
||||
stats = DiffStats()
|
||||
current_file = None
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("diff --git"):
|
||||
stats.files += 1
|
||||
current_file = self._extract_filename(line)
|
||||
if current_file and current_file not in stats.files_changed:
|
||||
stats.files_changed[current_file] = (0, 0)
|
||||
elif line.startswith("+") and not line.startswith("+++"):
|
||||
stats.additions += 1
|
||||
if current_file and current_file in stats.files_changed:
|
||||
adds, dels = stats.files_changed[current_file]
|
||||
stats.files_changed[current_file] = (adds + 1, dels)
|
||||
elif line.startswith("-") and not line.startswith("---"):
|
||||
stats.deletions += 1
|
||||
if current_file and current_file in stats.files_changed:
|
||||
adds, dels = stats.files_changed[current_file]
|
||||
stats.files_changed[current_file] = (adds, dels + 1)
|
||||
|
||||
return stats
|
||||
|
||||
def _print_summary(self, stats: DiffStats) -> None:
|
||||
"""
|
||||
Print diff statistics summary.
|
||||
|
||||
Args:
|
||||
stats: Diff statistics
|
||||
"""
|
||||
if stats.additions == 0 and stats.deletions == 0:
|
||||
return
|
||||
|
||||
summary = "[bold cyan]📊 Changes:[/bold cyan] "
|
||||
if stats.additions > 0:
|
||||
summary += f"[green]+{stats.additions}[/green]"
|
||||
if stats.additions > 0 and stats.deletions > 0:
|
||||
summary += " "
|
||||
if stats.deletions > 0:
|
||||
summary += f"[red]-{stats.deletions}[/red]"
|
||||
if stats.files > 1:
|
||||
summary += f" [dim]({stats.files} files)[/dim]"
|
||||
self.console.print(summary)
|
||||
self.console.print()
|
||||
|
||||
def _is_binary_file(self, diff_header: str) -> bool:
|
||||
"""
|
||||
Check if diff is for a binary file based on extension.
|
||||
|
||||
Args:
|
||||
diff_header: Diff header line (e.g., "diff --git a/file.png b/file.png")
|
||||
|
||||
Returns:
|
||||
True if file appears to be binary
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# Extract file path from diff header
|
||||
parts = diff_header.split()
|
||||
if len(parts) >= 3:
|
||||
file_path = parts[2] # e.g., "a/file.png"
|
||||
ext = Path(file_path).suffix.lower()
|
||||
return ext in self.BINARY_EXTENSIONS
|
||||
return False
|
||||
|
||||
def _extract_filename(self, diff_header: str) -> Optional[str]:
|
||||
"""
|
||||
Extract filename from diff header line.
|
||||
|
||||
Args:
|
||||
diff_header: Diff header line (e.g., "diff --git a/file.py b/file.py")
|
||||
|
||||
Returns:
|
||||
Filename or None if not found
|
||||
"""
|
||||
parts = diff_header.split()
|
||||
if len(parts) >= 3:
|
||||
# Extract from "a/file.py" or "b/file.py"
|
||||
file_path = parts[2]
|
||||
if file_path.startswith("a/") or file_path.startswith("b/"):
|
||||
return file_path[2:]
|
||||
return file_path
|
||||
return None
|
||||
|
||||
def _print_file_stats(self, filename: str, stats: DiffStats) -> None:
|
||||
"""
|
||||
Print per-file statistics with visual bar.
|
||||
|
||||
Args:
|
||||
filename: Name of the file
|
||||
stats: DiffStats containing per-file breakdown
|
||||
"""
|
||||
if filename and filename in stats.files_changed:
|
||||
adds, dels = stats.files_changed[filename]
|
||||
if adds > 0 or dels > 0:
|
||||
# Calculate visual bar proportions (max 30 chars)
|
||||
total_changes = adds + dels
|
||||
bar_width = min(30, total_changes)
|
||||
|
||||
if total_changes > 0:
|
||||
add_width = int((adds / total_changes) * bar_width)
|
||||
del_width = bar_width - add_width
|
||||
|
||||
# Create visual bar
|
||||
bar = ""
|
||||
if add_width > 0:
|
||||
bar += f"[bold green]{'▓' * add_width}[/bold green]"
|
||||
if del_width > 0:
|
||||
bar += f"[bold red]{'▓' * del_width}[/bold red]"
|
||||
|
||||
# Print stats with bar
|
||||
summary = f" {bar} "
|
||||
if adds > 0:
|
||||
summary += f"[bold green]+{adds}[/bold green]"
|
||||
if adds > 0 and dels > 0:
|
||||
summary += " "
|
||||
if dels > 0:
|
||||
summary += f"[bold red]-{dels}[/bold red]"
|
||||
self.console.print(summary)
|
||||
|
||||
def _format_hunk_header(self, hunk: str) -> str:
|
||||
"""
|
||||
Format hunk header with enhanced readability and context highlighting.
|
||||
|
||||
Transforms: @@ -140,7 +140,7 @@ class RalphConsole:
|
||||
Into: @@ Lines 140-147 → 140-147 @@ class RalphConsole:
|
||||
With context (function/class name) highlighted in cyan.
|
||||
|
||||
Args:
|
||||
hunk: Original hunk header line
|
||||
|
||||
Returns:
|
||||
Formatted hunk header with improved readability
|
||||
"""
|
||||
# Extract line ranges using regex
|
||||
pattern = r"@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$"
|
||||
match = re.search(pattern, hunk)
|
||||
|
||||
if not match:
|
||||
return hunk
|
||||
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2)) if match.group(2) else 1
|
||||
new_start = int(match.group(3))
|
||||
new_count = int(match.group(4)) if match.group(4) else 1
|
||||
context = match.group(5).strip()
|
||||
|
||||
# Calculate end lines
|
||||
old_end = old_start + old_count - 1
|
||||
new_end = new_start + new_count - 1
|
||||
|
||||
# Format with readable line ranges
|
||||
header = f"@@ Lines {old_start}-{old_end} → {new_start}-{new_end} @@"
|
||||
|
||||
# Highlight context (function/class name) if present
|
||||
if context:
|
||||
# Highlight the context in cyan for better visibility
|
||||
header += f" [cyan]{context}[/cyan]"
|
||||
|
||||
return header
|
||||
|
||||
|
||||
class RalphConsole:
|
||||
"""Rich console wrapper for Ralph output."""
|
||||
|
||||
# Display constants
|
||||
CLEAR_LINE_WIDTH = 80 # Characters to clear when clearing a line
|
||||
PROGRESS_BAR_WIDTH = 30 # Width of progress bar in characters
|
||||
COUNTDOWN_COLOR_CHANGE_THRESHOLD_HIGH = 5 # Seconds remaining for yellow
|
||||
COUNTDOWN_COLOR_CHANGE_THRESHOLD_LOW = 2 # Seconds remaining for red
|
||||
MARKDOWN_INDICATOR_THRESHOLD = 2 # Minimum markdown patterns to consider as markdown
|
||||
DIFF_SCAN_LINE_LIMIT = 5 # Number of lines to scan for diff indicators
|
||||
DIFF_HUNK_SCAN_CHARS = 100 # Characters to scan for diff hunk markers
|
||||
|
||||
# Regex patterns for content detection and formatting
|
||||
CODE_BLOCK_PATTERN = r"```(\w+)?\n(.*?)\n```"
|
||||
FILE_REF_PATTERN = r"(\S+\.[a-zA-Z0-9]+):(\d+)"
|
||||
INLINE_CODE_PATTERN = r"`([^`\n]+)`"
|
||||
HUNK_HEADER_PATTERN = r"@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$"
|
||||
TABLE_SEPARATOR_PATTERN = r"^\s*\|[\s\-:|]+\|\s*$"
|
||||
MARKDOWN_HEADING_PATTERN = r"^#{1,6}\s+.+"
|
||||
MARKDOWN_UNORDERED_LIST_PATTERN = r"^[\*\-]\s+.+"
|
||||
MARKDOWN_ORDERED_LIST_PATTERN = r"^\d+\.\s+.+"
|
||||
MARKDOWN_BOLD_PATTERN = r"\*\*.+?\*\*"
|
||||
MARKDOWN_ITALIC_PATTERN = r"\*.+?\*"
|
||||
MARKDOWN_BLOCKQUOTE_PATTERN = r"^>\s+.+"
|
||||
MARKDOWN_TASK_LIST_PATTERN = r"^[\*\-]\s+\[([ xX])\]\s+.+"
|
||||
MARKDOWN_HORIZONTAL_RULE_PATTERN = r"^(\-{3,}|\*{3,}|_{3,})\s*$"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Rich console."""
|
||||
if RICH_AVAILABLE:
|
||||
self.console = Console()
|
||||
self.diff_formatter = DiffFormatter(self.console)
|
||||
else:
|
||||
self.console = None
|
||||
self.diff_formatter = None
|
||||
|
||||
def print_status(self, message: str, style: str = "cyan") -> None:
|
||||
"""Print status message."""
|
||||
if self.console:
|
||||
# Use markup escaping to prevent Rich from parsing brackets in the icon
|
||||
self.console.print(f"[{style}][[*]] {message}[/{style}]")
|
||||
else:
|
||||
print(f"[*] {message}")
|
||||
|
||||
def print_success(self, message: str) -> None:
|
||||
"""Print success message."""
|
||||
if self.console:
|
||||
self.console.print(f"[green]✓[/green] {message}")
|
||||
else:
|
||||
print(f"✓ {message}")
|
||||
|
||||
def print_error(self, message: str, severity: str = "error") -> None:
|
||||
"""
|
||||
Print error message with severity-based formatting.
|
||||
|
||||
Args:
|
||||
message: Error message to print
|
||||
severity: Error severity level ("critical", "error", "warning")
|
||||
"""
|
||||
severity_styles = {
|
||||
"critical": ("[red bold]⛔[/red bold]", "red bold"),
|
||||
"error": ("[red]✗[/red]", "red"),
|
||||
"warning": ("[yellow]⚠[/yellow]", "yellow"),
|
||||
}
|
||||
|
||||
icon, style = severity_styles.get(severity, severity_styles["error"])
|
||||
if self.console:
|
||||
self.console.print(f"{icon} [{style}]{message}[/{style}]")
|
||||
else:
|
||||
print(f"✗ {message}")
|
||||
|
||||
def print_warning(self, message: str) -> None:
|
||||
"""Print warning message."""
|
||||
if self.console:
|
||||
self.console.print(f"[yellow]⚠[/yellow] {message}")
|
||||
else:
|
||||
print(f"⚠ {message}")
|
||||
|
||||
def print_info(self, message: str) -> None:
|
||||
"""Print info message."""
|
||||
if self.console:
|
||||
self.console.print(f"[blue]ℹ[/blue] {message}")
|
||||
else:
|
||||
print(f"ℹ {message}")
|
||||
|
||||
def print_header(self, title: str) -> None:
|
||||
"""Print section header."""
|
||||
if self.console and Panel:
|
||||
self.console.print(
|
||||
Panel(title, style="green bold", border_style="green"),
|
||||
justify="left",
|
||||
)
|
||||
else:
|
||||
print(f"\n=== {title} ===\n")
|
||||
|
||||
def print_iteration_header(self, iteration: int) -> None:
|
||||
"""Print iteration header."""
|
||||
if self.console:
|
||||
self.console.print(
|
||||
f"\n[cyan bold]=== RALPH ITERATION {iteration} ===[/cyan bold]\n"
|
||||
)
|
||||
else:
|
||||
print(f"\n=== RALPH ITERATION {iteration} ===\n")
|
||||
|
||||
def print_stats(
|
||||
self,
|
||||
iteration: int,
|
||||
success_count: int,
|
||||
error_count: int,
|
||||
start_time: str,
|
||||
prompt_file: str,
|
||||
recent_lines: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Print statistics table.
|
||||
|
||||
Args:
|
||||
iteration: Current iteration number
|
||||
success_count: Number of successful iterations
|
||||
error_count: Number of failed iterations
|
||||
start_time: Start time string
|
||||
prompt_file: Prompt file name
|
||||
recent_lines: Recent log entries
|
||||
"""
|
||||
if not self.console or not Table:
|
||||
# Plain text fallback
|
||||
print("\nRALPH STATISTICS")
|
||||
print(f" Iteration: {iteration}")
|
||||
print(f" Successful: {success_count}")
|
||||
print(f" Failed: {error_count}")
|
||||
print(f" Started: {start_time}")
|
||||
print(f" Prompt: {prompt_file}")
|
||||
return
|
||||
|
||||
# Create stats table with better formatting
|
||||
table = Table(
|
||||
title="🤖 RALPH STATISTICS",
|
||||
show_header=True,
|
||||
header_style="bold yellow",
|
||||
border_style="cyan",
|
||||
)
|
||||
table.add_column("Metric", style="cyan bold", no_wrap=True, width=20)
|
||||
table.add_column("Value", style="white", width=40)
|
||||
|
||||
# Calculate success rate
|
||||
total = success_count + error_count
|
||||
success_rate = (success_count / total * 100) if total > 0 else 0
|
||||
|
||||
table.add_row("🔄 Current Iteration", str(iteration))
|
||||
table.add_row("✅ Successful", f"[green bold]{success_count}[/green bold]")
|
||||
table.add_row("❌ Failed", f"[red bold]{error_count}[/red bold]")
|
||||
|
||||
# Determine success rate color based on percentage
|
||||
if success_rate > 80:
|
||||
rate_color = "green"
|
||||
elif success_rate > 50:
|
||||
rate_color = "yellow"
|
||||
else:
|
||||
rate_color = "red"
|
||||
table.add_row("📊 Success Rate", f"[{rate_color}]{success_rate:.1f}%[/]")
|
||||
|
||||
table.add_row("🕐 Started", start_time or "Unknown")
|
||||
table.add_row("📝 Prompt", prompt_file)
|
||||
|
||||
self.console.print(table)
|
||||
|
||||
# Show recent activity with better formatting
|
||||
if recent_lines:
|
||||
self.console.print("\n[yellow bold]📋 RECENT ACTIVITY[/yellow bold]")
|
||||
for line in recent_lines:
|
||||
# Clean up log lines for display and escape Rich markup
|
||||
clean_line = escape(line.strip())
|
||||
if "[SUCCESS]" in clean_line:
|
||||
self.console.print(f" [green]▸[/green] {clean_line}")
|
||||
elif "[ERROR]" in clean_line:
|
||||
self.console.print(f" [red]▸[/red] {clean_line}")
|
||||
elif "[WARNING]" in clean_line:
|
||||
self.console.print(f" [yellow]▸[/yellow] {clean_line}")
|
||||
else:
|
||||
self.console.print(f" [blue]▸[/blue] {clean_line}")
|
||||
self.console.print()
|
||||
|
||||
def print_countdown(self, remaining: int, total: int) -> None:
|
||||
"""
|
||||
Print countdown timer with progress bar.
|
||||
|
||||
Args:
|
||||
remaining: Seconds remaining
|
||||
total: Total delay seconds
|
||||
"""
|
||||
# Guard against division by zero
|
||||
if total <= 0:
|
||||
return
|
||||
|
||||
# Calculate progress
|
||||
progress = (total - remaining) / total
|
||||
filled = int(self.PROGRESS_BAR_WIDTH * progress)
|
||||
bar = "█" * filled + "░" * (self.PROGRESS_BAR_WIDTH - filled)
|
||||
|
||||
# Color based on time remaining (using constants)
|
||||
if remaining > self.COUNTDOWN_COLOR_CHANGE_THRESHOLD_HIGH:
|
||||
color = "green"
|
||||
elif remaining > self.COUNTDOWN_COLOR_CHANGE_THRESHOLD_LOW:
|
||||
color = "yellow"
|
||||
else:
|
||||
color = "red"
|
||||
|
||||
if self.console:
|
||||
self.console.print(
|
||||
f"\r[{color}]⏳ [{bar}] {remaining}s / {total}s remaining[/{color}]",
|
||||
end="",
|
||||
)
|
||||
else:
|
||||
print(f"\r⏳ [{bar}] {remaining}s / {total}s remaining", end="")
|
||||
|
||||
def clear_line(self) -> None:
|
||||
"""Clear current line."""
|
||||
if self.console:
|
||||
self.console.print("\r" + " " * self.CLEAR_LINE_WIDTH + "\r", end="")
|
||||
else:
|
||||
print("\r" + " " * self.CLEAR_LINE_WIDTH + "\r", end="")
|
||||
|
||||
def print_separator(self) -> None:
|
||||
"""Print visual separator."""
|
||||
if self.console:
|
||||
self.console.print("\n[cyan]---[/cyan]\n")
|
||||
else:
|
||||
print("\n---\n")
|
||||
|
||||
def clear_screen(self) -> None:
|
||||
"""Clear screen."""
|
||||
if self.console:
|
||||
self.console.clear()
|
||||
else:
|
||||
print("\033[2J\033[H", end="")
|
||||
|
||||
def print_message(self, text: str) -> None:
|
||||
"""
|
||||
Print message with intelligent formatting and improved visual hierarchy.
|
||||
|
||||
Detects and formats:
|
||||
- Code blocks (```language) with syntax highlighting
|
||||
- Diffs (lines starting with +, -, @@) with enhanced visualization
|
||||
- Markdown tables with proper rendering
|
||||
- Markdown headings, lists, emphasis with spacing
|
||||
- Inline code (`code`) with highlighting
|
||||
- Plain text with file path detection
|
||||
|
||||
Args:
|
||||
text: Message text to print
|
||||
"""
|
||||
if not self.console:
|
||||
print(text)
|
||||
return
|
||||
|
||||
# Check if text contains code blocks
|
||||
if "```" in text:
|
||||
# Split text by code blocks and process each part
|
||||
parts = re.split(self.CODE_BLOCK_PATTERN, text, flags=re.DOTALL)
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if i % 3 == 0: # Regular text between code blocks
|
||||
if part.strip():
|
||||
self._print_formatted_text(part)
|
||||
# Add subtle spacing after text before code block
|
||||
if i + 1 < len(parts):
|
||||
self.console.print()
|
||||
elif i % 3 == 1: # Language identifier
|
||||
language = part or "text"
|
||||
code = parts[i + 1] if i + 1 < len(parts) else ""
|
||||
if code.strip() and Syntax:
|
||||
# Use syntax highlighting for code blocks with enhanced features
|
||||
syntax = Syntax(
|
||||
code,
|
||||
language,
|
||||
theme="monokai",
|
||||
line_numbers=True,
|
||||
word_wrap=True,
|
||||
indent_guides=True,
|
||||
padding=(1, 2),
|
||||
)
|
||||
self.console.print(syntax)
|
||||
# Add spacing after code block if more content follows
|
||||
if i + 2 < len(parts) and parts[i + 2].strip():
|
||||
self.console.print()
|
||||
elif self._is_diff_content(text):
|
||||
# Format as diff with enhanced visualization
|
||||
if self.diff_formatter:
|
||||
self.diff_formatter.format_and_print(text)
|
||||
else:
|
||||
print(text)
|
||||
elif self._is_markdown_table(text):
|
||||
# Render markdown tables nicely
|
||||
self._print_markdown_table(text)
|
||||
# Add spacing after table
|
||||
self.console.print()
|
||||
elif self._is_markdown_content(text):
|
||||
# Render rich markdown with headings, lists, emphasis
|
||||
self._print_markdown(text)
|
||||
else:
|
||||
# Regular text - check for inline code and format accordingly
|
||||
self._print_formatted_text(text)
|
||||
|
||||
def _is_diff_content(self, text: str) -> bool:
|
||||
"""
|
||||
Check if text appears to be diff content.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text looks like diff output
|
||||
"""
|
||||
diff_indicators = [
|
||||
text.startswith("diff --git"),
|
||||
text.startswith("--- "),
|
||||
text.startswith("+++ "),
|
||||
"@@" in text[: self.DIFF_HUNK_SCAN_CHARS], # Diff hunk markers
|
||||
any(
|
||||
line.startswith(("+", "-", "@@"))
|
||||
for line in text.split("\n")[: self.DIFF_SCAN_LINE_LIMIT]
|
||||
),
|
||||
]
|
||||
return any(diff_indicators)
|
||||
|
||||
def _is_markdown_table(self, text: str) -> bool:
|
||||
"""
|
||||
Check if text appears to be a markdown table.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text looks like a markdown table
|
||||
"""
|
||||
lines = text.strip().split("\n")
|
||||
if len(lines) < 2:
|
||||
return False
|
||||
|
||||
# Check for table separator line (e.g., |---|---|)
|
||||
for line in lines[:3]:
|
||||
if re.match(self.TABLE_SEPARATOR_PATTERN, line):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _print_markdown_table(self, text: str) -> None:
|
||||
"""
|
||||
Print markdown table with Rich formatting.
|
||||
|
||||
Args:
|
||||
text: Markdown table text
|
||||
"""
|
||||
if Markdown:
|
||||
# Use Rich's Markdown renderer for tables
|
||||
md = Markdown(text)
|
||||
self.console.print(md)
|
||||
else:
|
||||
print(text)
|
||||
|
||||
def _is_markdown_content(self, text: str) -> bool:
|
||||
"""
|
||||
Check if text appears to contain rich markdown (headings, lists, etc.).
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text looks like markdown with formatting
|
||||
"""
|
||||
markdown_indicators = [
|
||||
re.search(self.MARKDOWN_HEADING_PATTERN, text, re.MULTILINE), # Headings
|
||||
re.search(
|
||||
self.MARKDOWN_UNORDERED_LIST_PATTERN, text, re.MULTILINE
|
||||
), # Unordered lists
|
||||
re.search(
|
||||
self.MARKDOWN_ORDERED_LIST_PATTERN, text, re.MULTILINE
|
||||
), # Ordered lists
|
||||
re.search(self.MARKDOWN_BOLD_PATTERN, text), # Bold
|
||||
re.search(self.MARKDOWN_ITALIC_PATTERN, text), # Italic
|
||||
re.search(
|
||||
self.MARKDOWN_BLOCKQUOTE_PATTERN, text, re.MULTILINE
|
||||
), # Blockquotes
|
||||
re.search(
|
||||
self.MARKDOWN_TASK_LIST_PATTERN, text, re.MULTILINE
|
||||
), # Task lists
|
||||
re.search(
|
||||
self.MARKDOWN_HORIZONTAL_RULE_PATTERN, text, re.MULTILINE
|
||||
), # Horizontal rules
|
||||
]
|
||||
# Return true if at least MARKDOWN_INDICATOR_THRESHOLD markdown indicators present
|
||||
threshold = self.MARKDOWN_INDICATOR_THRESHOLD
|
||||
return sum(bool(indicator) for indicator in markdown_indicators) >= threshold
|
||||
|
||||
def _preprocess_markdown(self, text: str) -> str:
|
||||
"""
|
||||
Preprocess markdown text for better rendering.
|
||||
|
||||
Handles:
|
||||
- Task lists with checkboxes (- [ ] and - [x])
|
||||
- Horizontal rules with visual enhancement
|
||||
- Code blocks with language hints
|
||||
|
||||
Args:
|
||||
text: Raw markdown text
|
||||
|
||||
Returns:
|
||||
Preprocessed markdown text
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
processed_lines = []
|
||||
|
||||
for line in lines:
|
||||
# Enhanced task lists with visual indicators
|
||||
if re.match(self.MARKDOWN_TASK_LIST_PATTERN, line):
|
||||
# Replace [ ] with ☐ and [x]/[X] with ☑
|
||||
line = re.sub(r"\[\s\]", "☐", line)
|
||||
line = re.sub(r"\[[xX]\]", "☑", line)
|
||||
|
||||
# Enhanced horizontal rules - make them more visible
|
||||
if re.match(self.MARKDOWN_HORIZONTAL_RULE_PATTERN, line):
|
||||
line = f"\n{'─' * 60}\n"
|
||||
|
||||
processed_lines.append(line)
|
||||
|
||||
return "\n".join(processed_lines)
|
||||
|
||||
def _print_markdown(self, text: str) -> None:
|
||||
"""
|
||||
Print markdown content with Rich formatting and improved spacing.
|
||||
|
||||
Args:
|
||||
text: Markdown text to render
|
||||
"""
|
||||
if not Markdown:
|
||||
print(text)
|
||||
return
|
||||
|
||||
# Add subtle spacing before markdown for visual separation
|
||||
has_heading = re.search(self.MARKDOWN_HEADING_PATTERN, text, re.MULTILINE)
|
||||
if has_heading:
|
||||
self.console.print()
|
||||
|
||||
# Preprocess markdown for enhanced features
|
||||
processed_text = self._preprocess_markdown(text)
|
||||
|
||||
md = Markdown(processed_text)
|
||||
self.console.print(md)
|
||||
|
||||
# Add spacing after markdown blocks for better separation from next content
|
||||
self.console.print()
|
||||
|
||||
def _print_formatted_text(self, text: str) -> None:
|
||||
"""
|
||||
Print text with basic formatting, inline code, file path highlighting, and error detection.
|
||||
|
||||
Args:
|
||||
text: Text to print
|
||||
"""
|
||||
if not self.console:
|
||||
print(text)
|
||||
return
|
||||
|
||||
# Check for error/exception patterns and apply special formatting
|
||||
if self._is_error_traceback(text):
|
||||
self._print_error_traceback(text)
|
||||
return
|
||||
|
||||
# First, highlight file paths with line numbers (e.g., "file.py:123")
|
||||
text = re.sub(
|
||||
self.FILE_REF_PATTERN,
|
||||
lambda m: (
|
||||
f"[bold yellow]{m.group(1)}[/bold yellow]:"
|
||||
f"[bold blue]{m.group(2)}[/bold blue]"
|
||||
),
|
||||
text,
|
||||
)
|
||||
|
||||
# Check for inline code (single backticks)
|
||||
if "`" in text and "```" not in text:
|
||||
# Replace inline code with Rich markup - improved visibility
|
||||
formatted_text = re.sub(
|
||||
self.INLINE_CODE_PATTERN,
|
||||
lambda m: f"[cyan on grey23]{m.group(1)}[/cyan on grey23]",
|
||||
text,
|
||||
)
|
||||
self.console.print(formatted_text, highlight=True)
|
||||
else:
|
||||
# Enable markup for file paths and highlighting for URLs
|
||||
self.console.print(text, markup=True, highlight=True)
|
||||
|
||||
def _is_error_traceback(self, text: str) -> bool:
|
||||
"""
|
||||
Check if text appears to be an error traceback.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text looks like an error traceback
|
||||
"""
|
||||
error_indicators = [
|
||||
"Traceback (most recent call last):" in text,
|
||||
re.search(r'^\s*File ".*", line \d+', text, re.MULTILINE),
|
||||
re.search(
|
||||
r"^(Error|Exception|ValueError|TypeError|RuntimeError):",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
),
|
||||
]
|
||||
return any(error_indicators)
|
||||
|
||||
def _print_error_traceback(self, text: str) -> None:
|
||||
"""
|
||||
Print error traceback with enhanced formatting.
|
||||
|
||||
Args:
|
||||
text: Error traceback text
|
||||
"""
|
||||
if not Syntax:
|
||||
print(text)
|
||||
return
|
||||
|
||||
# Use Python syntax highlighting for tracebacks
|
||||
try:
|
||||
syntax = Syntax(
|
||||
text,
|
||||
"python",
|
||||
theme="monokai",
|
||||
line_numbers=False,
|
||||
word_wrap=True,
|
||||
background_color="grey11",
|
||||
)
|
||||
self.console.print("\n[red bold]⚠ Error Traceback:[/red bold]")
|
||||
self.console.print(syntax)
|
||||
self.console.print()
|
||||
except Exception as e:
|
||||
# Fallback to simple red text if syntax highlighting fails
|
||||
_logger.warning("Syntax highlighting failed for traceback: %s: %s", type(e).__name__, e)
|
||||
self.console.print(f"[red]{text}[/red]")
|
||||
@@ -0,0 +1,247 @@
|
||||
# ABOUTME: Content type detection for smart output formatting
|
||||
# ABOUTME: Detects diffs, code blocks, markdown, tables, and tracebacks
|
||||
|
||||
"""Content type detection for intelligent output formatting."""
|
||||
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ContentType(Enum):
|
||||
"""Types of content that can be detected."""
|
||||
|
||||
PLAIN_TEXT = "plain_text"
|
||||
DIFF = "diff"
|
||||
CODE_BLOCK = "code_block"
|
||||
MARKDOWN = "markdown"
|
||||
MARKDOWN_TABLE = "markdown_table"
|
||||
ERROR_TRACEBACK = "error_traceback"
|
||||
|
||||
|
||||
class ContentDetector:
|
||||
"""Detects content types for smart formatting.
|
||||
|
||||
Analyzes text content to determine the most appropriate rendering method.
|
||||
Detection priority: code_block > diff > traceback > table > markdown > plain
|
||||
"""
|
||||
|
||||
# Detection constants
|
||||
DIFF_HUNK_SCAN_CHARS = 100
|
||||
DIFF_SCAN_LINE_LIMIT = 5
|
||||
MARKDOWN_INDICATOR_THRESHOLD = 2
|
||||
|
||||
# Regex patterns
|
||||
CODE_BLOCK_PATTERN = re.compile(r"```(\w+)?\n.*?\n```", re.DOTALL)
|
||||
MARKDOWN_HEADING_PATTERN = re.compile(r"^#{1,6}\s+.+", re.MULTILINE)
|
||||
MARKDOWN_UNORDERED_LIST_PATTERN = re.compile(r"^[\*\-]\s+.+", re.MULTILINE)
|
||||
MARKDOWN_ORDERED_LIST_PATTERN = re.compile(r"^\d+\.\s+.+", re.MULTILINE)
|
||||
MARKDOWN_BOLD_PATTERN = re.compile(r"\*\*.+?\*\*")
|
||||
MARKDOWN_ITALIC_PATTERN = re.compile(r"(?<!\*)\*(?!\*)[^*\n]+\*(?!\*)")
|
||||
MARKDOWN_BLOCKQUOTE_PATTERN = re.compile(r"^>\s+.+", re.MULTILINE)
|
||||
MARKDOWN_TASK_LIST_PATTERN = re.compile(r"^[\*\-]\s+\[([ xX])\]\s+.+", re.MULTILINE)
|
||||
MARKDOWN_HORIZONTAL_RULE_PATTERN = re.compile(r"^(\-{3,}|\*{3,}|_{3,})\s*$", re.MULTILINE)
|
||||
TABLE_SEPARATOR_PATTERN = re.compile(r"^\s*\|[\s\-:|]+\|\s*$", re.MULTILINE)
|
||||
TRACEBACK_FILE_LINE_PATTERN = re.compile(r'^\s*File ".*", line \d+', re.MULTILINE)
|
||||
TRACEBACK_ERROR_PATTERN = re.compile(
|
||||
r"^(Error|Exception|ValueError|TypeError|RuntimeError|KeyError|AttributeError|"
|
||||
r"IndexError|ImportError|FileNotFoundError|NameError|ZeroDivisionError):",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
def detect(self, text: str) -> ContentType:
|
||||
"""Detect the primary content type of text.
|
||||
|
||||
Detection priority ensures more specific types are matched first:
|
||||
1. Code blocks (```...```) - highest priority
|
||||
2. Diffs (git diff format)
|
||||
3. Error tracebacks (Python exceptions)
|
||||
4. Markdown tables (|...|)
|
||||
5. Rich markdown (headings, lists, etc.)
|
||||
6. Plain text (fallback)
|
||||
|
||||
Args:
|
||||
text: Text content to analyze
|
||||
|
||||
Returns:
|
||||
The detected ContentType
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return ContentType.PLAIN_TEXT
|
||||
|
||||
# Check in priority order
|
||||
if self.is_code_block(text):
|
||||
return ContentType.CODE_BLOCK
|
||||
|
||||
if self.is_diff(text):
|
||||
return ContentType.DIFF
|
||||
|
||||
if self.is_error_traceback(text):
|
||||
return ContentType.ERROR_TRACEBACK
|
||||
|
||||
if self.is_markdown_table(text):
|
||||
return ContentType.MARKDOWN_TABLE
|
||||
|
||||
if self.is_markdown(text):
|
||||
return ContentType.MARKDOWN
|
||||
|
||||
return ContentType.PLAIN_TEXT
|
||||
|
||||
def is_diff(self, text: str) -> bool:
|
||||
"""Check if text is diff content.
|
||||
|
||||
Detects git diff format including:
|
||||
- diff --git headers
|
||||
- --- and +++ file markers (as diff markers, not markdown hr)
|
||||
- @@ hunk markers
|
||||
|
||||
Note: We avoid matching lines that merely start with + or - as those
|
||||
could be markdown list items. Diff detection requires more specific
|
||||
markers like @@ hunks or diff --git headers.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text appears to be diff content
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
# Check for definitive diff markers
|
||||
diff_indicators = [
|
||||
text.startswith("diff --git"),
|
||||
"@@" in text[: self.DIFF_HUNK_SCAN_CHARS],
|
||||
]
|
||||
|
||||
if any(diff_indicators):
|
||||
return True
|
||||
|
||||
# Check for --- a/ and +++ b/ patterns (file markers in unified diff)
|
||||
# These are more specific than just --- or +++ which could be markdown hr
|
||||
lines = text.split("\n")[: self.DIFF_SCAN_LINE_LIMIT]
|
||||
has_file_markers = (
|
||||
any(line.startswith("--- a/") or line.startswith("--- /") for line in lines)
|
||||
and any(line.startswith("+++ b/") or line.startswith("+++ /") for line in lines)
|
||||
)
|
||||
if has_file_markers:
|
||||
return True
|
||||
|
||||
# Check for @@ hunk pattern specifically
|
||||
return any(line.startswith("@@") for line in lines)
|
||||
|
||||
def is_code_block(self, text: str) -> bool:
|
||||
"""Check if text contains fenced code blocks.
|
||||
|
||||
Detects markdown-style code blocks with triple backticks.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text contains code blocks
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
return "```" in text and self.CODE_BLOCK_PATTERN.search(text) is not None
|
||||
|
||||
def is_markdown(self, text: str) -> bool:
|
||||
"""Check if text contains rich markdown formatting.
|
||||
|
||||
Requires at least MARKDOWN_INDICATOR_THRESHOLD indicators to avoid
|
||||
false positives on text that happens to contain a single markdown element.
|
||||
|
||||
Detected elements:
|
||||
- Headings (# Title)
|
||||
- Lists (- item, 1. item)
|
||||
- Emphasis (**bold**, *italic*)
|
||||
- Blockquotes (> quote)
|
||||
- Task lists (- [ ] task)
|
||||
- Horizontal rules (---)
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text appears to be markdown content
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
markdown_indicators = [
|
||||
self.MARKDOWN_HEADING_PATTERN.search(text),
|
||||
self.MARKDOWN_UNORDERED_LIST_PATTERN.search(text),
|
||||
self.MARKDOWN_ORDERED_LIST_PATTERN.search(text),
|
||||
self.MARKDOWN_BOLD_PATTERN.search(text),
|
||||
self.MARKDOWN_ITALIC_PATTERN.search(text),
|
||||
self.MARKDOWN_BLOCKQUOTE_PATTERN.search(text),
|
||||
self.MARKDOWN_TASK_LIST_PATTERN.search(text),
|
||||
self.MARKDOWN_HORIZONTAL_RULE_PATTERN.search(text),
|
||||
]
|
||||
|
||||
return sum(bool(indicator) for indicator in markdown_indicators) >= self.MARKDOWN_INDICATOR_THRESHOLD
|
||||
|
||||
def is_markdown_table(self, text: str) -> bool:
|
||||
"""Check if text is a markdown table.
|
||||
|
||||
Detects tables with pipe separators and header dividers.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text appears to be a markdown table
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
lines = text.strip().split("\n")
|
||||
if len(lines) < 2:
|
||||
return False
|
||||
|
||||
# Check for table separator line (|---|---|) in first few lines
|
||||
for line in lines[:3]:
|
||||
if self.TABLE_SEPARATOR_PATTERN.match(line):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_error_traceback(self, text: str) -> bool:
|
||||
"""Check if text is an error traceback.
|
||||
|
||||
Detects Python exception tracebacks.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text appears to be an error traceback
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
|
||||
error_indicators = [
|
||||
"Traceback (most recent call last):" in text,
|
||||
self.TRACEBACK_FILE_LINE_PATTERN.search(text),
|
||||
self.TRACEBACK_ERROR_PATTERN.search(text),
|
||||
]
|
||||
return any(error_indicators)
|
||||
|
||||
def extract_code_blocks(self, text: str) -> list[tuple[Optional[str], str]]:
|
||||
"""Extract code blocks from text.
|
||||
|
||||
Args:
|
||||
text: Text containing code blocks
|
||||
|
||||
Returns:
|
||||
List of (language, code) tuples. Language may be None.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
blocks = []
|
||||
pattern = re.compile(r"```(\w+)?\n(.*?)\n```", re.DOTALL)
|
||||
for match in pattern.finditer(text):
|
||||
language = match.group(1)
|
||||
code = match.group(2)
|
||||
blocks.append((language, code))
|
||||
return blocks
|
||||
@@ -0,0 +1,416 @@
|
||||
# ABOUTME: JSON output formatter for programmatic consumption
|
||||
# ABOUTME: Produces structured JSON output for parsing by other tools
|
||||
|
||||
"""JSON output formatter for Claude adapter."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import (
|
||||
MessageType,
|
||||
OutputFormatter,
|
||||
ToolCallInfo,
|
||||
VerbosityLevel,
|
||||
)
|
||||
|
||||
|
||||
class JsonFormatter(OutputFormatter):
|
||||
"""JSON formatter for programmatic output consumption.
|
||||
|
||||
Produces structured JSON output suitable for parsing by other tools,
|
||||
logging systems, or downstream processing pipelines.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verbosity: VerbosityLevel = VerbosityLevel.NORMAL,
|
||||
pretty: bool = True,
|
||||
include_timestamps: bool = True,
|
||||
) -> None:
|
||||
"""Initialize JSON formatter.
|
||||
|
||||
Args:
|
||||
verbosity: Output verbosity level
|
||||
pretty: Pretty-print JSON with indentation
|
||||
include_timestamps: Include timestamps in output
|
||||
"""
|
||||
super().__init__(verbosity)
|
||||
self._pretty = pretty
|
||||
self._include_timestamps = include_timestamps
|
||||
self._events: List[Dict[str, Any]] = []
|
||||
|
||||
def _to_json(self, obj: Dict[str, Any]) -> str:
|
||||
"""Convert object to JSON string.
|
||||
|
||||
Args:
|
||||
obj: Dictionary to serialize
|
||||
|
||||
Returns:
|
||||
JSON string
|
||||
"""
|
||||
if self._pretty:
|
||||
return json.dumps(obj, indent=2, default=str, ensure_ascii=False)
|
||||
return json.dumps(obj, default=str, ensure_ascii=False)
|
||||
|
||||
def _create_event(
|
||||
self,
|
||||
event_type: str,
|
||||
data: Dict[str, Any],
|
||||
iteration: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a structured event object.
|
||||
|
||||
Args:
|
||||
event_type: Type of event
|
||||
data: Event data
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Event dictionary
|
||||
"""
|
||||
event = {
|
||||
"type": event_type,
|
||||
"iteration": iteration,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
if self._include_timestamps:
|
||||
event["timestamp"] = datetime.now().isoformat()
|
||||
|
||||
return event
|
||||
|
||||
def _record_event(self, event: Dict[str, Any]) -> None:
|
||||
"""Record event for later retrieval.
|
||||
|
||||
Args:
|
||||
event: Event dictionary to record
|
||||
"""
|
||||
self._events.append(event)
|
||||
|
||||
def get_events(self) -> List[Dict[str, Any]]:
|
||||
"""Get all recorded events.
|
||||
|
||||
Returns:
|
||||
List of event dictionaries
|
||||
"""
|
||||
return self._events.copy()
|
||||
|
||||
def clear_events(self) -> None:
|
||||
"""Clear recorded events."""
|
||||
self._events.clear()
|
||||
|
||||
def format_tool_call(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool call as JSON.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call information
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.TOOL_CALL):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.TOOL_CALL, tool_info, context)
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"tool_name": tool_info.tool_name,
|
||||
"tool_id": tool_info.tool_id,
|
||||
}
|
||||
|
||||
if self._verbosity.value >= VerbosityLevel.VERBOSE.value:
|
||||
data["input_params"] = tool_info.input_params
|
||||
|
||||
if tool_info.start_time:
|
||||
data["start_time"] = tool_info.start_time.isoformat()
|
||||
|
||||
event = self._create_event("tool_call", data, iteration)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool result as JSON.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call info with result
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.TOOL_RESULT):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.TOOL_RESULT, tool_info, context)
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"tool_name": tool_info.tool_name,
|
||||
"tool_id": tool_info.tool_id,
|
||||
"is_error": tool_info.is_error,
|
||||
}
|
||||
|
||||
if tool_info.duration_ms is not None:
|
||||
data["duration_ms"] = tool_info.duration_ms
|
||||
|
||||
if self._verbosity.value >= VerbosityLevel.VERBOSE.value:
|
||||
result = tool_info.result
|
||||
if isinstance(result, str) and len(result) > 1000:
|
||||
data["result"] = self.summarize_content(result, 1000)
|
||||
data["result_truncated"] = True
|
||||
data["result_full_length"] = len(result)
|
||||
else:
|
||||
data["result"] = result
|
||||
|
||||
if tool_info.end_time:
|
||||
data["end_time"] = tool_info.end_time.isoformat()
|
||||
|
||||
event = self._create_event("tool_result", data, iteration)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_assistant_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an assistant message as JSON.
|
||||
|
||||
Args:
|
||||
message: Assistant message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.ASSISTANT):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.ASSISTANT, message, context)
|
||||
|
||||
data: Dict[str, Any] = {}
|
||||
|
||||
if self._verbosity == VerbosityLevel.NORMAL and len(message) > 1000:
|
||||
data["message"] = self.summarize_content(message, 1000)
|
||||
data["message_truncated"] = True
|
||||
data["message_full_length"] = len(message)
|
||||
else:
|
||||
data["message"] = message
|
||||
data["message_truncated"] = False
|
||||
|
||||
data["message_length"] = len(message)
|
||||
|
||||
event = self._create_event("assistant_message", data, iteration)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_system_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a system message as JSON.
|
||||
|
||||
Args:
|
||||
message: System message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.SYSTEM):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.SYSTEM, message, context)
|
||||
|
||||
data = {
|
||||
"message": message,
|
||||
}
|
||||
|
||||
event = self._create_event("system_message", data, iteration)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_error(
|
||||
self,
|
||||
error: str,
|
||||
exception: Optional[Exception] = None,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an error as JSON.
|
||||
|
||||
Args:
|
||||
error: Error message
|
||||
exception: Optional exception object
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.ERROR, error, context)
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"error": error,
|
||||
}
|
||||
|
||||
if exception:
|
||||
data["exception_type"] = type(exception).__name__
|
||||
data["exception_str"] = str(exception)
|
||||
|
||||
if self._verbosity.value >= VerbosityLevel.VERBOSE.value:
|
||||
import traceback
|
||||
|
||||
data["traceback"] = traceback.format_exception(
|
||||
type(exception), exception, exception.__traceback__
|
||||
)
|
||||
|
||||
event = self._create_event("error", data, iteration)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_progress(
|
||||
self,
|
||||
message: str,
|
||||
current: int = 0,
|
||||
total: int = 0,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format progress information as JSON.
|
||||
|
||||
Args:
|
||||
message: Progress message
|
||||
current: Current progress value
|
||||
total: Total progress value
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.PROGRESS):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.PROGRESS, message, context)
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"message": message,
|
||||
"current": current,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
if total > 0:
|
||||
data["percentage"] = round((current / total) * 100, 1)
|
||||
|
||||
event = self._create_event("progress", data, iteration)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_token_usage(self, show_session: bool = True) -> str:
|
||||
"""Format token usage summary as JSON.
|
||||
|
||||
Args:
|
||||
show_session: Include session totals
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
usage = self._token_usage
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"current": {
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"total_tokens": usage.total_tokens,
|
||||
"cost": usage.cost,
|
||||
},
|
||||
}
|
||||
|
||||
if show_session:
|
||||
data["session"] = {
|
||||
"input_tokens": usage.session_input_tokens,
|
||||
"output_tokens": usage.session_output_tokens,
|
||||
"total_tokens": usage.session_total_tokens,
|
||||
"cost": usage.session_cost,
|
||||
}
|
||||
|
||||
if usage.model:
|
||||
data["model"] = usage.model
|
||||
|
||||
event = self._create_event("token_usage", data, 0)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_section_header(self, title: str, iteration: int = 0) -> str:
|
||||
"""Format a section header as JSON.
|
||||
|
||||
Args:
|
||||
title: Section title
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
data = {
|
||||
"title": title,
|
||||
"elapsed_seconds": self.get_elapsed_time(),
|
||||
}
|
||||
|
||||
event = self._create_event("section_start", data, iteration)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def format_section_footer(self) -> str:
|
||||
"""Format a section footer as JSON.
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
data = {
|
||||
"elapsed_seconds": self.get_elapsed_time(),
|
||||
}
|
||||
|
||||
event = self._create_event("section_end", data, 0)
|
||||
self._record_event(event)
|
||||
return self._to_json(event)
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of all recorded events.
|
||||
|
||||
Returns:
|
||||
Summary dictionary with counts and totals
|
||||
"""
|
||||
event_counts: Dict[str, int] = {}
|
||||
for event in self._events:
|
||||
event_type = event.get("type", "unknown")
|
||||
event_counts[event_type] = event_counts.get(event_type, 0) + 1
|
||||
|
||||
return {
|
||||
"total_events": len(self._events),
|
||||
"event_counts": event_counts,
|
||||
"token_usage": {
|
||||
"total_tokens": self._token_usage.session_total_tokens,
|
||||
"total_cost": self._token_usage.session_cost,
|
||||
},
|
||||
"elapsed_seconds": self.get_elapsed_time(),
|
||||
}
|
||||
|
||||
def export_events(self) -> str:
|
||||
"""Export all recorded events as a JSON array.
|
||||
|
||||
Returns:
|
||||
JSON string with all events
|
||||
"""
|
||||
return self._to_json({"events": self._events, "summary": self.get_summary()})
|
||||
@@ -0,0 +1,298 @@
|
||||
# ABOUTME: Plain text output formatter for non-terminal environments
|
||||
# ABOUTME: Provides basic text formatting without colors or special characters
|
||||
|
||||
"""Plain text output formatter for Claude adapter."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .base import (
|
||||
MessageType,
|
||||
OutputFormatter,
|
||||
ToolCallInfo,
|
||||
VerbosityLevel,
|
||||
)
|
||||
|
||||
|
||||
class PlainTextFormatter(OutputFormatter):
|
||||
"""Plain text formatter for environments without rich terminal support.
|
||||
|
||||
Produces readable output without ANSI codes, colors, or special characters.
|
||||
Suitable for logging to files or basic terminal output.
|
||||
"""
|
||||
|
||||
# Formatting constants
|
||||
SEPARATOR_WIDTH = 60
|
||||
HEADER_CHAR = "="
|
||||
SUBHEADER_CHAR = "-"
|
||||
SECTION_CHAR = "#"
|
||||
|
||||
def __init__(self, verbosity: VerbosityLevel = VerbosityLevel.NORMAL) -> None:
|
||||
"""Initialize plain text formatter.
|
||||
|
||||
Args:
|
||||
verbosity: Output verbosity level
|
||||
"""
|
||||
super().__init__(verbosity)
|
||||
|
||||
def _timestamp(self) -> str:
|
||||
"""Get formatted timestamp string."""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _separator(self, char: str = "-", width: int = None) -> str:
|
||||
"""Create a separator line."""
|
||||
return char * (width or self.SEPARATOR_WIDTH)
|
||||
|
||||
def format_tool_call(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool call for plain text display.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call information
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.TOOL_CALL):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.TOOL_CALL, tool_info, context)
|
||||
|
||||
lines = [
|
||||
self._separator(),
|
||||
f"[{self._timestamp()}] TOOL CALL: {tool_info.tool_name}",
|
||||
f" ID: {tool_info.tool_id[:12]}...",
|
||||
]
|
||||
|
||||
if self._verbosity.value >= VerbosityLevel.VERBOSE.value:
|
||||
if tool_info.input_params:
|
||||
lines.append(" Input Parameters:")
|
||||
for key, value in tool_info.input_params.items():
|
||||
value_str = str(value)
|
||||
if len(value_str) > 100:
|
||||
value_str = value_str[:97] + "..."
|
||||
lines.append(f" {key}: {value_str}")
|
||||
|
||||
lines.append(self._separator())
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool result for plain text display.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call info with result
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.TOOL_RESULT):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.TOOL_RESULT, tool_info, context)
|
||||
|
||||
status = "ERROR" if tool_info.is_error else "Success"
|
||||
duration = f" ({tool_info.duration_ms}ms)" if tool_info.duration_ms else ""
|
||||
|
||||
lines = [
|
||||
f"[TOOL RESULT] {tool_info.tool_name}{duration}",
|
||||
f" ID: {tool_info.tool_id[:12]}...",
|
||||
f" Status: {status}",
|
||||
]
|
||||
|
||||
if self._verbosity.value >= VerbosityLevel.VERBOSE.value and tool_info.result:
|
||||
result_str = str(tool_info.result)
|
||||
if len(result_str) > 500:
|
||||
result_str = self.summarize_content(result_str, 500)
|
||||
lines.append(" Output:")
|
||||
for line in result_str.split("\n"):
|
||||
lines.append(f" {line}")
|
||||
|
||||
lines.append(self._separator())
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_assistant_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an assistant message for plain text display.
|
||||
|
||||
Args:
|
||||
message: Assistant message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.ASSISTANT):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.ASSISTANT, message, context)
|
||||
|
||||
if self._verbosity == VerbosityLevel.QUIET:
|
||||
return ""
|
||||
|
||||
# Summarize if too long and not verbose
|
||||
if self._verbosity == VerbosityLevel.NORMAL and len(message) > 1000:
|
||||
message = self.summarize_content(message, 1000)
|
||||
|
||||
return f"[{self._timestamp()}] ASSISTANT:\n{message}\n"
|
||||
|
||||
def format_system_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a system message for plain text display.
|
||||
|
||||
Args:
|
||||
message: System message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.SYSTEM):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.SYSTEM, message, context)
|
||||
|
||||
return f"[{self._timestamp()}] SYSTEM: {message}\n"
|
||||
|
||||
def format_error(
|
||||
self,
|
||||
error: str,
|
||||
exception: Optional[Exception] = None,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an error for plain text display.
|
||||
|
||||
Args:
|
||||
error: Error message
|
||||
exception: Optional exception object
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.ERROR, error, context)
|
||||
|
||||
lines = [
|
||||
self._separator(self.HEADER_CHAR),
|
||||
f"[{self._timestamp()}] ERROR (Iteration {iteration})",
|
||||
f" Message: {error}",
|
||||
]
|
||||
|
||||
if exception and self._verbosity.value >= VerbosityLevel.VERBOSE.value:
|
||||
lines.append(f" Type: {type(exception).__name__}")
|
||||
import traceback
|
||||
|
||||
tb = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
|
||||
lines.append(" Traceback:")
|
||||
for line in tb.split("\n"):
|
||||
lines.append(f" {line}")
|
||||
|
||||
lines.append(self._separator(self.HEADER_CHAR))
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_progress(
|
||||
self,
|
||||
message: str,
|
||||
current: int = 0,
|
||||
total: int = 0,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format progress information for plain text display.
|
||||
|
||||
Args:
|
||||
message: Progress message
|
||||
current: Current progress value
|
||||
total: Total progress value
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.PROGRESS):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.PROGRESS, message, context)
|
||||
|
||||
if total > 0:
|
||||
pct = (current / total) * 100
|
||||
bar_width = 30
|
||||
filled = int(bar_width * current / total)
|
||||
bar = "#" * filled + "-" * (bar_width - filled)
|
||||
return f"[{bar}] {pct:.1f}% - {message}"
|
||||
return f"[...] {message}"
|
||||
|
||||
def format_token_usage(self, show_session: bool = True) -> str:
|
||||
"""Format token usage summary for plain text display.
|
||||
|
||||
Args:
|
||||
show_session: Include session totals
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
usage = self._token_usage
|
||||
lines = [
|
||||
self._separator(self.SUBHEADER_CHAR),
|
||||
"TOKEN USAGE:",
|
||||
f" Current: {usage.total_tokens:,} tokens (${usage.cost:.4f})",
|
||||
f" Input: {usage.input_tokens:,} | Output: {usage.output_tokens:,}",
|
||||
]
|
||||
|
||||
if show_session:
|
||||
lines.extend([
|
||||
f" Session: {usage.session_total_tokens:,} tokens (${usage.session_cost:.4f})",
|
||||
f" Input: {usage.session_input_tokens:,} | Output: {usage.session_output_tokens:,}",
|
||||
])
|
||||
|
||||
if usage.model:
|
||||
lines.append(f" Model: {usage.model}")
|
||||
|
||||
lines.append(self._separator(self.SUBHEADER_CHAR))
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_section_header(self, title: str, iteration: int = 0) -> str:
|
||||
"""Format a section header for plain text display.
|
||||
|
||||
Args:
|
||||
title: Section title
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
lines = [
|
||||
"",
|
||||
self._separator(self.HEADER_CHAR),
|
||||
f"{title} (Iteration {iteration})" if iteration else title,
|
||||
self._separator(self.HEADER_CHAR),
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_section_footer(self) -> str:
|
||||
"""Format a section footer for plain text display.
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
elapsed = self.get_elapsed_time()
|
||||
return f"\n{self._separator(self.SUBHEADER_CHAR)}\nElapsed: {elapsed:.1f}s\n"
|
||||
@@ -0,0 +1,737 @@
|
||||
# ABOUTME: Rich terminal formatter with colors, panels, and progress indicators
|
||||
# ABOUTME: Provides visually enhanced output using the Rich library with smart content detection
|
||||
|
||||
"""Rich terminal output formatter for Claude adapter.
|
||||
|
||||
This formatter provides intelligent content detection and rendering:
|
||||
- Diffs are rendered with color-coded additions/deletions
|
||||
- Code blocks get syntax highlighting
|
||||
- Markdown is rendered with proper formatting
|
||||
- Error tracebacks are highlighted for readability
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from typing import Optional
|
||||
|
||||
from .base import (
|
||||
MessageType,
|
||||
OutputFormatter,
|
||||
ToolCallInfo,
|
||||
VerbosityLevel,
|
||||
)
|
||||
from .content_detector import ContentDetector, ContentType
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import Rich components with fallback
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
||||
from rich.syntax import Syntax
|
||||
from rich.markup import escape
|
||||
|
||||
RICH_AVAILABLE = True
|
||||
except ImportError:
|
||||
RICH_AVAILABLE = False
|
||||
Console = None # type: ignore
|
||||
Panel = None # type: ignore
|
||||
|
||||
|
||||
class RichTerminalFormatter(OutputFormatter):
|
||||
"""Rich terminal formatter with colors, panels, and progress indicators.
|
||||
|
||||
Provides visually enhanced output using the Rich library for terminal
|
||||
display. Falls back to plain text if Rich is not available.
|
||||
"""
|
||||
|
||||
# Color scheme
|
||||
COLORS = {
|
||||
"tool_name": "bold cyan",
|
||||
"tool_id": "dim",
|
||||
"success": "bold green",
|
||||
"error": "bold red",
|
||||
"warning": "yellow",
|
||||
"info": "blue",
|
||||
"timestamp": "dim white",
|
||||
"header": "bold magenta",
|
||||
"assistant": "white",
|
||||
"system": "dim cyan",
|
||||
"token_input": "green",
|
||||
"token_output": "yellow",
|
||||
"cost": "bold yellow",
|
||||
}
|
||||
|
||||
# Icons
|
||||
ICONS = {
|
||||
"tool": "",
|
||||
"success": "",
|
||||
"error": "",
|
||||
"warning": "",
|
||||
"info": "",
|
||||
"assistant": "",
|
||||
"system": "",
|
||||
"token": "",
|
||||
"clock": "",
|
||||
"progress": "",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verbosity: VerbosityLevel = VerbosityLevel.NORMAL,
|
||||
console: Optional["Console"] = None,
|
||||
smart_detection: bool = True,
|
||||
) -> None:
|
||||
"""Initialize rich terminal formatter.
|
||||
|
||||
Args:
|
||||
verbosity: Output verbosity level
|
||||
console: Optional Rich console instance (creates new if None)
|
||||
smart_detection: Enable smart content detection (diff, code, markdown)
|
||||
"""
|
||||
super().__init__(verbosity)
|
||||
self._rich_available = RICH_AVAILABLE
|
||||
self._smart_detection = smart_detection
|
||||
self._content_detector = ContentDetector() if smart_detection else None
|
||||
|
||||
if RICH_AVAILABLE:
|
||||
self._console = console or Console()
|
||||
# Import DiffFormatter for diff rendering
|
||||
from .console import DiffFormatter
|
||||
self._diff_formatter = DiffFormatter(self._console)
|
||||
else:
|
||||
self._console = None
|
||||
self._diff_formatter = None
|
||||
|
||||
@property
|
||||
def console(self) -> Optional["Console"]:
|
||||
"""Get the Rich console instance."""
|
||||
return self._console
|
||||
|
||||
def _timestamp(self) -> str:
|
||||
"""Get formatted timestamp string with Rich markup."""
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
if self._rich_available:
|
||||
return f"[{self.COLORS['timestamp']}]{ts}[/]"
|
||||
return ts
|
||||
|
||||
def _full_timestamp(self) -> str:
|
||||
"""Get full timestamp with date."""
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if self._rich_available:
|
||||
return f"[{self.COLORS['timestamp']}]{ts}[/]"
|
||||
return ts
|
||||
|
||||
def format_tool_call(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool call for rich terminal display.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call information
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.TOOL_CALL):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.TOOL_CALL, tool_info, context)
|
||||
|
||||
if not self._rich_available:
|
||||
return self._format_tool_call_plain(tool_info)
|
||||
|
||||
# Build rich formatted output
|
||||
icon = self.ICONS["tool"]
|
||||
name_color = self.COLORS["tool_name"]
|
||||
id_color = self.COLORS["tool_id"]
|
||||
|
||||
lines = [
|
||||
f"{icon} [{name_color}]TOOL CALL: {tool_info.tool_name}[/]",
|
||||
f" [{id_color}]ID: {tool_info.tool_id[:12]}...[/]",
|
||||
]
|
||||
|
||||
if self._verbosity.value >= VerbosityLevel.VERBOSE.value:
|
||||
if tool_info.input_params:
|
||||
lines.append(f" [{self.COLORS['info']}]Input Parameters:[/]")
|
||||
for key, value in tool_info.input_params.items():
|
||||
value_str = str(value)
|
||||
if len(value_str) > 100:
|
||||
value_str = value_str[:97] + "..."
|
||||
# Escape Rich markup in values
|
||||
if self._rich_available:
|
||||
value_str = escape(value_str)
|
||||
lines.append(f" - {key}: {value_str}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_tool_call_plain(self, tool_info: ToolCallInfo) -> str:
|
||||
"""Plain fallback for tool call formatting."""
|
||||
lines = [
|
||||
f"TOOL CALL: {tool_info.tool_name}",
|
||||
f" ID: {tool_info.tool_id[:12]}...",
|
||||
]
|
||||
if tool_info.input_params:
|
||||
for key, value in tool_info.input_params.items():
|
||||
value_str = str(value)[:100]
|
||||
lines.append(f" {key}: {value_str}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_tool_result(
|
||||
self,
|
||||
tool_info: ToolCallInfo,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a tool result for rich terminal display.
|
||||
|
||||
Args:
|
||||
tool_info: Tool call info with result
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.TOOL_RESULT):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.TOOL_RESULT, tool_info, context)
|
||||
|
||||
if not self._rich_available:
|
||||
return self._format_tool_result_plain(tool_info)
|
||||
|
||||
# Determine status styling
|
||||
if tool_info.is_error:
|
||||
status_icon = self.ICONS["error"]
|
||||
status_color = self.COLORS["error"]
|
||||
status_text = "ERROR"
|
||||
else:
|
||||
status_icon = self.ICONS["success"]
|
||||
status_color = self.COLORS["success"]
|
||||
status_text = "Success"
|
||||
|
||||
duration = f" ({tool_info.duration_ms}ms)" if tool_info.duration_ms else ""
|
||||
|
||||
lines = [
|
||||
f"{status_icon} [{status_color}]TOOL RESULT: {tool_info.tool_name}{duration}[/]",
|
||||
f" [{self.COLORS['tool_id']}]ID: {tool_info.tool_id[:12]}...[/]",
|
||||
f" Status: [{status_color}]{status_text}[/]",
|
||||
]
|
||||
|
||||
if self._verbosity.value >= VerbosityLevel.VERBOSE.value and tool_info.result:
|
||||
result_str = str(tool_info.result)
|
||||
if len(result_str) > 500:
|
||||
result_str = self.summarize_content(result_str, 500)
|
||||
# Escape Rich markup in result
|
||||
if self._rich_available:
|
||||
result_str = escape(result_str)
|
||||
lines.append(f" [{self.COLORS['info']}]Output:[/]")
|
||||
for line in result_str.split("\n")[:20]: # Limit lines
|
||||
lines.append(f" {line}")
|
||||
if result_str.count("\n") > 20:
|
||||
lines.append(f" [{self.COLORS['timestamp']}]... ({result_str.count(chr(10)) - 20} more lines)[/]")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_tool_result_plain(self, tool_info: ToolCallInfo) -> str:
|
||||
"""Plain fallback for tool result formatting."""
|
||||
status = "ERROR" if tool_info.is_error else "Success"
|
||||
lines = [
|
||||
f"TOOL RESULT: {tool_info.tool_name}",
|
||||
f" Status: {status}",
|
||||
]
|
||||
if tool_info.result:
|
||||
lines.append(f" Output: {str(tool_info.result)[:200]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_assistant_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an assistant message for rich terminal display.
|
||||
|
||||
With smart_detection enabled, detects and renders:
|
||||
- Diffs with color-coded additions/deletions
|
||||
- Code blocks with syntax highlighting
|
||||
- Markdown with proper formatting
|
||||
- Error tracebacks with special highlighting
|
||||
|
||||
Args:
|
||||
message: Assistant message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.ASSISTANT):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.ASSISTANT, message, context)
|
||||
|
||||
if self._verbosity == VerbosityLevel.QUIET:
|
||||
return ""
|
||||
|
||||
# Summarize if needed (only for normal verbosity)
|
||||
display_message = message
|
||||
if self._verbosity == VerbosityLevel.NORMAL and len(message) > 1000:
|
||||
display_message = self.summarize_content(message, 1000)
|
||||
|
||||
if not self._rich_available:
|
||||
return f"ASSISTANT: {display_message}"
|
||||
|
||||
# Use smart detection if enabled
|
||||
if self._smart_detection and self._content_detector:
|
||||
content_type = self._content_detector.detect(display_message)
|
||||
return self._render_smart_content(display_message, content_type)
|
||||
|
||||
# Fallback to simple formatting
|
||||
icon = self.ICONS["assistant"]
|
||||
return f"{icon} [{self.COLORS['assistant']}]{escape(display_message)}[/]"
|
||||
|
||||
def _render_smart_content(self, text: str, content_type: ContentType) -> str:
|
||||
"""Render content based on detected type.
|
||||
|
||||
Args:
|
||||
text: Text content to render
|
||||
content_type: Detected content type
|
||||
|
||||
Returns:
|
||||
Formatted string (may include Rich markup)
|
||||
"""
|
||||
if content_type == ContentType.DIFF:
|
||||
return self._render_diff(text)
|
||||
elif content_type == ContentType.CODE_BLOCK:
|
||||
return self._render_code_blocks(text)
|
||||
elif content_type == ContentType.MARKDOWN:
|
||||
return self._render_markdown(text)
|
||||
elif content_type == ContentType.MARKDOWN_TABLE:
|
||||
return self._render_markdown(text) # Tables use markdown renderer
|
||||
elif content_type == ContentType.ERROR_TRACEBACK:
|
||||
return self._render_traceback(text)
|
||||
else:
|
||||
# Plain text - escape and format
|
||||
icon = self.ICONS["assistant"]
|
||||
return f"{icon} [{self.COLORS['assistant']}]{escape(text)}[/]"
|
||||
|
||||
def _render_diff(self, text: str) -> str:
|
||||
"""Render diff content with colors.
|
||||
|
||||
Uses the DiffFormatter for enhanced diff visualization with
|
||||
color-coded additions/deletions and file statistics.
|
||||
|
||||
Args:
|
||||
text: Diff text to render
|
||||
|
||||
Returns:
|
||||
Empty string (diff is printed directly to console)
|
||||
"""
|
||||
if self._diff_formatter and self._console:
|
||||
# DiffFormatter prints directly, so capture would require buffer
|
||||
# For now, print directly and return marker
|
||||
self._diff_formatter.format_and_print(text)
|
||||
return "" # Already printed
|
||||
return f"[dim]{text}[/dim]"
|
||||
|
||||
def _render_code_blocks(self, text: str) -> str:
|
||||
"""Render text with code blocks using syntax highlighting.
|
||||
|
||||
Extracts code blocks, renders them with Rich Syntax, and
|
||||
formats the surrounding text.
|
||||
|
||||
Args:
|
||||
text: Text containing code blocks
|
||||
|
||||
Returns:
|
||||
Formatted string with code block markers for print_smart()
|
||||
"""
|
||||
if not self._console or not self._content_detector:
|
||||
return f"[dim]{text}[/dim]"
|
||||
|
||||
# Split by code blocks and render each part
|
||||
parts = []
|
||||
pattern = r"```(\w+)?\n(.*?)\n```"
|
||||
last_end = 0
|
||||
|
||||
for match in re.finditer(pattern, text, re.DOTALL):
|
||||
# Text before code block
|
||||
before = text[last_end:match.start()].strip()
|
||||
if before:
|
||||
parts.append(("text", before))
|
||||
|
||||
# Code block
|
||||
language = match.group(1) or "text"
|
||||
code = match.group(2)
|
||||
parts.append(("code", language, code))
|
||||
last_end = match.end()
|
||||
|
||||
# Text after last code block
|
||||
after = text[last_end:].strip()
|
||||
if after:
|
||||
parts.append(("text", after))
|
||||
|
||||
# Render to string buffer using console
|
||||
buffer = StringIO()
|
||||
temp_console = Console(file=buffer, force_terminal=True, width=100)
|
||||
|
||||
for part in parts:
|
||||
if part[0] == "text":
|
||||
temp_console.print(part[1], markup=True, highlight=True)
|
||||
else: # code
|
||||
_, language, code = part
|
||||
syntax = Syntax(
|
||||
code,
|
||||
language,
|
||||
theme="monokai",
|
||||
line_numbers=True,
|
||||
word_wrap=True,
|
||||
)
|
||||
temp_console.print(syntax)
|
||||
|
||||
return buffer.getvalue()
|
||||
|
||||
def _render_markdown(self, text: str) -> str:
|
||||
"""Render markdown content with Rich formatting.
|
||||
|
||||
Uses Rich's Markdown renderer for headings, lists, emphasis, etc.
|
||||
|
||||
Args:
|
||||
text: Markdown text to render
|
||||
|
||||
Returns:
|
||||
Formatted markdown string
|
||||
"""
|
||||
if not self._console:
|
||||
return text
|
||||
|
||||
try:
|
||||
from rich.markdown import Markdown
|
||||
|
||||
# Preprocess for task lists
|
||||
processed = self._preprocess_markdown(text)
|
||||
|
||||
buffer = StringIO()
|
||||
temp_console = Console(file=buffer, force_terminal=True, width=100)
|
||||
temp_console.print(Markdown(processed))
|
||||
return buffer.getvalue()
|
||||
except ImportError:
|
||||
return text
|
||||
|
||||
def _preprocess_markdown(self, text: str) -> str:
|
||||
"""Preprocess markdown for enhanced rendering.
|
||||
|
||||
Converts task list checkboxes to visual indicators.
|
||||
|
||||
Args:
|
||||
text: Raw markdown
|
||||
|
||||
Returns:
|
||||
Preprocessed markdown
|
||||
"""
|
||||
# Convert task lists: [ ] -> ☐, [x] -> ☑
|
||||
text = re.sub(r"\[\s\]", "☐", text)
|
||||
text = re.sub(r"\[[xX]\]", "☑", text)
|
||||
return text
|
||||
|
||||
def _render_traceback(self, text: str) -> str:
|
||||
"""Render error traceback with syntax highlighting.
|
||||
|
||||
Uses Python syntax highlighting for better readability.
|
||||
|
||||
Args:
|
||||
text: Traceback text to render
|
||||
|
||||
Returns:
|
||||
Formatted traceback string
|
||||
"""
|
||||
if not self._console:
|
||||
return f"[red]{text}[/red]"
|
||||
|
||||
try:
|
||||
buffer = StringIO()
|
||||
temp_console = Console(file=buffer, force_terminal=True, width=100)
|
||||
temp_console.print("[red bold]⚠ Error Traceback:[/red bold]")
|
||||
syntax = Syntax(
|
||||
text,
|
||||
"python",
|
||||
theme="monokai",
|
||||
line_numbers=False,
|
||||
word_wrap=True,
|
||||
background_color="grey11",
|
||||
)
|
||||
temp_console.print(syntax)
|
||||
return buffer.getvalue()
|
||||
except Exception as e:
|
||||
_logger.warning("Rich traceback rendering failed: %s: %s", type(e).__name__, e)
|
||||
return f"[red]{escape(text)}[/red]"
|
||||
|
||||
def print_smart(self, message: str, iteration: int = 0) -> None:
|
||||
"""Print message with smart content detection directly to console.
|
||||
|
||||
This is the preferred method for displaying assistant messages
|
||||
as it handles all content types appropriately and prints directly.
|
||||
|
||||
Args:
|
||||
message: Message text to print
|
||||
iteration: Current iteration number
|
||||
"""
|
||||
if not self.should_display(MessageType.ASSISTANT):
|
||||
return
|
||||
|
||||
if self._verbosity == VerbosityLevel.QUIET:
|
||||
return
|
||||
|
||||
if not self._console:
|
||||
print(f"ASSISTANT: {message}")
|
||||
return
|
||||
|
||||
# Use smart detection
|
||||
if self._smart_detection and self._content_detector:
|
||||
content_type = self._content_detector.detect(message)
|
||||
|
||||
if content_type == ContentType.DIFF:
|
||||
# DiffFormatter prints directly
|
||||
if self._diff_formatter:
|
||||
self._diff_formatter.format_and_print(message)
|
||||
return
|
||||
|
||||
# For other content types, use format and print
|
||||
formatted = self.format_assistant_message(message, iteration)
|
||||
if formatted:
|
||||
self._console.print(formatted, markup=True)
|
||||
|
||||
def format_system_message(
|
||||
self,
|
||||
message: str,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format a system message for rich terminal display.
|
||||
|
||||
Args:
|
||||
message: System message text
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.SYSTEM):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.SYSTEM, message, context)
|
||||
|
||||
if not self._rich_available:
|
||||
return f"SYSTEM: {message}"
|
||||
|
||||
icon = self.ICONS["system"]
|
||||
return f"{icon} [{self.COLORS['system']}]SYSTEM: {message}[/]"
|
||||
|
||||
def format_error(
|
||||
self,
|
||||
error: str,
|
||||
exception: Optional[Exception] = None,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format an error for rich terminal display.
|
||||
|
||||
Args:
|
||||
error: Error message
|
||||
exception: Optional exception object
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.ERROR, error, context)
|
||||
|
||||
if not self._rich_available:
|
||||
return f"ERROR: {error}"
|
||||
|
||||
icon = self.ICONS["error"]
|
||||
color = self.COLORS["error"]
|
||||
|
||||
lines = [
|
||||
f"\n{icon} [{color}]ERROR (Iteration {iteration})[/]",
|
||||
f" [{color}]{error}[/]",
|
||||
]
|
||||
|
||||
if exception and self._verbosity.value >= VerbosityLevel.VERBOSE.value:
|
||||
lines.append(f" [{self.COLORS['warning']}]Type: {type(exception).__name__}[/]")
|
||||
import traceback
|
||||
|
||||
tb = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
|
||||
lines.append(f" [{self.COLORS['timestamp']}]Traceback:[/]")
|
||||
for line in tb.split("\n")[:15]: # Limit traceback lines
|
||||
if line.strip():
|
||||
lines.append(f" {escape(line)}" if self._rich_available else f" {line}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_progress(
|
||||
self,
|
||||
message: str,
|
||||
current: int = 0,
|
||||
total: int = 0,
|
||||
iteration: int = 0,
|
||||
) -> str:
|
||||
"""Format progress information for rich terminal display.
|
||||
|
||||
Args:
|
||||
message: Progress message
|
||||
current: Current progress value
|
||||
total: Total progress value
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self.should_display(MessageType.PROGRESS):
|
||||
return ""
|
||||
|
||||
context = self._create_context(iteration)
|
||||
self._notify_callbacks(MessageType.PROGRESS, message, context)
|
||||
|
||||
if not self._rich_available:
|
||||
if total > 0:
|
||||
pct = (current / total) * 100
|
||||
return f"[{pct:.0f}%] {message}"
|
||||
return f"[...] {message}"
|
||||
|
||||
icon = self.ICONS["progress"]
|
||||
if total > 0:
|
||||
pct = (current / total) * 100
|
||||
bar_width = 20
|
||||
filled = int(bar_width * current / total)
|
||||
bar = "" * filled + "" * (bar_width - filled)
|
||||
return f"{icon} [{self.COLORS['info']}][{bar}] {pct:.0f}%[/] {message}"
|
||||
return f"{icon} [{self.COLORS['info']}][...][/] {message}"
|
||||
|
||||
def format_token_usage(self, show_session: bool = True) -> str:
|
||||
"""Format token usage summary for rich terminal display.
|
||||
|
||||
Args:
|
||||
show_session: Include session totals
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
usage = self._token_usage
|
||||
|
||||
if not self._rich_available:
|
||||
lines = [
|
||||
f"TOKEN USAGE: {usage.total_tokens:,} (${usage.cost:.4f})",
|
||||
]
|
||||
if show_session:
|
||||
lines.append(f" Session: {usage.session_total_tokens:,} (${usage.session_cost:.4f})")
|
||||
return "\n".join(lines)
|
||||
|
||||
icon = self.ICONS["token"]
|
||||
input_color = self.COLORS["token_input"]
|
||||
output_color = self.COLORS["token_output"]
|
||||
cost_color = self.COLORS["cost"]
|
||||
|
||||
lines = [
|
||||
f"\n{icon} [{self.COLORS['header']}]TOKEN USAGE[/]",
|
||||
f" Current: [{input_color}]{usage.input_tokens:,} in[/] | [{output_color}]{usage.output_tokens:,} out[/] | [{cost_color}]${usage.cost:.4f}[/]",
|
||||
]
|
||||
|
||||
if show_session:
|
||||
lines.append(
|
||||
f" Session: [{input_color}]{usage.session_input_tokens:,} in[/] | [{output_color}]{usage.session_output_tokens:,} out[/] | [{cost_color}]${usage.session_cost:.4f}[/]"
|
||||
)
|
||||
|
||||
if usage.model:
|
||||
lines.append(f" [{self.COLORS['timestamp']}]Model: {usage.model}[/]")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def format_section_header(self, title: str, iteration: int = 0) -> str:
|
||||
"""Format a section header for rich terminal display.
|
||||
|
||||
Args:
|
||||
title: Section title
|
||||
iteration: Current iteration number
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
if not self._rich_available:
|
||||
sep = "=" * 60
|
||||
header_title = f"{title} (Iteration {iteration})" if iteration else title
|
||||
return f"\n{sep}\n{header_title}\n{sep}"
|
||||
|
||||
header_title = f"{title} (Iteration {iteration})" if iteration else title
|
||||
sep = "" * 50
|
||||
return f"\n[{self.COLORS['header']}]{sep}\n{header_title}\n{sep}[/]"
|
||||
|
||||
def format_section_footer(self) -> str:
|
||||
"""Format a section footer for rich terminal display.
|
||||
|
||||
Returns:
|
||||
Formatted string representation
|
||||
"""
|
||||
elapsed = self.get_elapsed_time()
|
||||
|
||||
if not self._rich_available:
|
||||
return f"\n{'=' * 50}\nElapsed: {elapsed:.1f}s\n"
|
||||
|
||||
icon = self.ICONS["clock"]
|
||||
return f"\n[{self.COLORS['timestamp']}]{icon} Elapsed: {elapsed:.1f}s[/]\n"
|
||||
|
||||
def print(self, text: str) -> None:
|
||||
"""Print formatted text to console.
|
||||
|
||||
Args:
|
||||
text: Rich-formatted text to print
|
||||
"""
|
||||
if self._console:
|
||||
self._console.print(text, markup=True)
|
||||
else:
|
||||
# Strip markup for plain output
|
||||
import re
|
||||
|
||||
plain = re.sub(r"\[/?[^\]]+\]", "", text)
|
||||
print(plain)
|
||||
|
||||
def print_panel(self, content: str, title: str = "", border_style: str = "blue") -> None:
|
||||
"""Print content in a Rich panel.
|
||||
|
||||
Args:
|
||||
content: Content to display
|
||||
title: Panel title
|
||||
border_style: Panel border color
|
||||
"""
|
||||
if self._console and self._rich_available and Panel:
|
||||
panel = Panel(content, title=title, border_style=border_style)
|
||||
self._console.print(panel)
|
||||
else:
|
||||
if title:
|
||||
print(f"\n=== {title} ===")
|
||||
print(content)
|
||||
print()
|
||||
|
||||
def create_progress_bar(self) -> Optional["Progress"]:
|
||||
"""Create a Rich progress bar instance.
|
||||
|
||||
Returns:
|
||||
Progress instance or None if Rich not available
|
||||
"""
|
||||
if not self._rich_available or not Progress:
|
||||
return None
|
||||
|
||||
return Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
||||
console=self._console,
|
||||
)
|
||||
Reference in New Issue
Block a user