diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 93ef105b..26feecfb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -11,9 +11,12 @@ import html import mistune from jinja2 import Environment, FileSystemLoader, select_autoescape +from pygments import highlight # type: ignore[reportUnknownVariableType] +from pygments.lexers import get_lexer_for_filename, TextLexer # type: ignore[reportUnknownVariableType] +from pygments.formatters import HtmlFormatter # type: ignore[reportUnknownVariableType] +from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType] from .models import ( - AssistantTranscriptEntry, TranscriptEntry, SummaryTranscriptEntry, SystemTranscriptEntry, @@ -37,6 +40,33 @@ from .cache import get_library_version +def starts_with_emoji(text: str) -> bool: + """Check if a string starts with an emoji character. + + Checks common emoji Unicode ranges: + - Emoticons: U+1F600 - U+1F64F + - Misc Symbols and Pictographs: U+1F300 - U+1F5FF + - Transport and Map Symbols: U+1F680 - U+1F6FF + - Supplemental Symbols: U+1F900 - U+1F9FF + - Misc Symbols: U+2600 - U+26FF + - Dingbats: U+2700 - U+27BF + """ + if not text: + return False + + first_char = text[0] + code_point = ord(first_char) + + return ( + 0x1F600 <= code_point <= 0x1F64F # Emoticons + or 0x1F300 <= code_point <= 0x1F5FF # Misc Symbols and Pictographs + or 0x1F680 <= code_point <= 0x1F6FF # Transport and Map Symbols + or 0x1F900 <= code_point <= 0x1F9FF # Supplemental Symbols + or 0x2600 <= code_point <= 0x26FF # Misc Symbols + or 0x2700 <= code_point <= 0x27BF # Dingbats + ) + + def get_project_display_name( project_dir_name: str, working_directories: Optional[List[str]] = None ) -> str: @@ -175,8 +205,44 @@ def create_collapsible_details( """ +def _create_pygments_plugin() -> Any: + """Create a mistune plugin that uses Pygments for code block syntax highlighting.""" + from pygments import highlight # type: ignore[reportUnknownVariableType] + from pygments.lexers import get_lexer_by_name, TextLexer # type: ignore[reportUnknownVariableType] + from pygments.formatters import HtmlFormatter # type: ignore[reportUnknownVariableType] + from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType] + + def plugin_pygments(md: Any) -> None: + """Plugin to add Pygments syntax highlighting to code blocks.""" + original_render = md.renderer.block_code + + def block_code(code: str, info: Optional[str] = None) -> str: + """Render code block with Pygments syntax highlighting if language is specified.""" + if info: + # Language hint provided, use Pygments + lang = info.split()[0] if info else "" + try: + lexer = get_lexer_by_name(lang, stripall=True) # type: ignore[reportUnknownVariableType] + except ClassNotFound: + lexer = TextLexer() # type: ignore[reportUnknownVariableType] + + formatter = HtmlFormatter( # type: ignore[reportUnknownVariableType] + linenos=False, # No line numbers in markdown code blocks + cssclass="highlight", + wrapcode=True, + ) + return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType] + else: + # No language hint, use default rendering + return original_render(code, info) + + md.renderer.block_code = block_code + + return plugin_pygments + + def render_markdown(text: str) -> str: - """Convert markdown text to HTML using mistune.""" + """Convert markdown text to HTML using mistune with Pygments syntax highlighting.""" # Configure mistune with GitHub-flavored markdown features renderer = mistune.create_markdown( plugins=[ @@ -186,6 +252,7 @@ def render_markdown(text: str) -> str: "url", "task_lists", "def_list", + _create_pygments_plugin(), ], escape=False, # Don't escape HTML since we want to render markdown properly hard_wrap=True, # Line break for newlines (checklists in Assistant messages) @@ -290,19 +357,103 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str: """ +def _highlight_code_with_pygments( + code: str, file_path: str, show_linenos: bool = True, linenostart: int = 1 +) -> str: + """Highlight code using Pygments with appropriate lexer based on file path. + + Args: + code: The source code to highlight + file_path: Path to determine the appropriate lexer + show_linenos: Whether to show line numbers (default: True) + linenostart: Starting line number for display (default: 1) + + Returns: + HTML string with syntax-highlighted code + """ + try: + # Try to get lexer based on filename + lexer = get_lexer_for_filename(file_path, code) # type: ignore[reportUnknownVariableType] + except ClassNotFound: + # Fall back to plain text lexer + lexer = TextLexer() # type: ignore[reportUnknownVariableType] + + # Create formatter with line numbers in table format + formatter = HtmlFormatter( # type: ignore[reportUnknownVariableType] + linenos="table" if show_linenos else False, + cssclass="highlight", + wrapcode=True, + linenostart=linenostart, + ) + + # Highlight the code + return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType] + + +def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 + """Format Read tool use content showing file path. + + Note: File path is now shown in the header, so we skip content here. + """ + # File path is now shown in header, so no content needed + # Don't show offset/limit parameters as they'll be visible in the result + return "" + + +def format_write_tool_content(tool_use: ToolUseContent) -> str: + """Format Write tool use content with Pygments syntax highlighting. + + Note: File path is now shown in the header, so we skip it here. + """ + file_path = tool_use.input.get("file_path", "") + content = tool_use.input.get("content", "") + + html_parts = ["
"] + + # File path is now shown in header, so we skip it here + + # Highlight code with Pygments + highlighted_html = _highlight_code_with_pygments(content, file_path) + + # Make collapsible if content has more than 12 lines + lines = content.split("\n") + if len(lines) > 12: + # Get preview (first ~5 lines) + preview_lines = lines[:5] + preview_html = _highlight_code_with_pygments( + "\n".join(preview_lines), file_path + ) + + html_parts.append(f""" +
+ + {len(lines)} lines +
{preview_html}
+
+
{highlighted_html}
+
+ """) + else: + # Show directly without collapsible + html_parts.append(f"
{highlighted_html}
") + + html_parts.append("
") + + return "".join(html_parts) + + def format_bash_tool_content(tool_use: ToolUseContent) -> str: - """Format Bash tool use content in VS Code extension style.""" + """Format Bash tool use content in VS Code extension style. + + Note: Description is now shown in the header, so we skip it here. + """ command = tool_use.input.get("command", "") - description = tool_use.input.get("description", "") escaped_command = escape_html(command) html_parts = ["
"] - # Add description if present - if description: - escaped_desc = escape_html(description) - html_parts.append(f"
{escaped_desc}
") + # Description is now shown in header, so we skip it here # Add command in preformatted block html_parts.append(f"
{escaped_command}
") @@ -373,26 +524,12 @@ def render_params_table(params: Dict[str, Any]) -> str: return "".join(html_parts) -def format_edit_tool_content(tool_use: ToolUseContent) -> str: - """Format Edit tool use content as a diff view with intra-line highlighting.""" - import difflib - - file_path = tool_use.input.get("file_path", "") - old_string = tool_use.input.get("old_string", "") - new_string = tool_use.input.get("new_string", "") - replace_all = tool_use.input.get("replace_all", False) - - escaped_path = escape_html(file_path) - - html_parts = ["
"] - - # File path header - html_parts.append(f"
πŸ“ {escaped_path}
") +def _render_single_diff(old_string: str, new_string: str) -> str: + """Render a single diff between old_string and new_string. - if replace_all: - html_parts.append( - "
πŸ”„ Replace all occurrences
" - ) + Returns HTML for the diff view with intra-line highlighting. + """ + import difflib # Split into lines for diff old_lines = old_string.splitlines(keepends=True) @@ -402,7 +539,7 @@ def format_edit_tool_content(tool_use: ToolUseContent) -> str: differ = difflib.Differ() diff: list[str] = list(differ.compare(old_lines, new_lines)) - html_parts.append("
") + html_parts = ["
"] i = 0 while i < len(diff): @@ -481,7 +618,59 @@ def format_edit_tool_content(tool_use: ToolUseContent) -> str: ) i += 1 - html_parts.append("
") + html_parts.append("
") + return "".join(html_parts) + + +def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: + """Format Multiedit tool use content showing multiple diffs.""" + file_path = tool_use.input.get("file_path", "") + edits = tool_use.input.get("edits", []) + + escaped_path = escape_html(file_path) + + html_parts = ["
"] + + # File path header + html_parts.append(f"
πŸ“ {escaped_path}
") + html_parts.append(f"
Applying {len(edits)} edits
") + + # Render each edit as a diff + for idx, edit in enumerate(edits, 1): + old_string = edit.get("old_string", "") + new_string = edit.get("new_string", "") + + html_parts.append( + f"
Edit #{idx}
" + ) + html_parts.append(_render_single_diff(old_string, new_string)) + html_parts.append("
") + + html_parts.append("
") + return "".join(html_parts) + + +def format_edit_tool_content(tool_use: ToolUseContent) -> str: + """Format Edit tool use content as a diff view with intra-line highlighting. + + Note: File path is now shown in the header, so we skip it here. + """ + old_string = tool_use.input.get("old_string", "") + new_string = tool_use.input.get("new_string", "") + replace_all = tool_use.input.get("replace_all", False) + + html_parts = ["
"] + + # File path is now shown in header, so we skip it here + + if replace_all: + html_parts.append( + "
πŸ”„ Replace all occurrences
" + ) + + # Use shared diff rendering helper + html_parts.append(_render_single_diff(old_string, new_string)) + html_parts.append("
") return "".join(html_parts) @@ -526,6 +715,29 @@ def _render_line_diff(old_line: str, new_line: str) -> str: return "".join(old_parts) + "".join(new_parts) +def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: + """Extract a one-line summary from tool parameters for display in header. + + Returns a brief description or filename that can be shown in the message header + to save vertical space. + """ + tool_name = tool_use.name + params = tool_use.input + + if tool_name == "Bash": + # Return description if present + return params.get("description") + + elif tool_name in ("Read", "Edit", "Write"): + # Return file path (without icon - caller adds it) + file_path = params.get("file_path") + if file_path: + return file_path + + # No summary for other tools + return None + + def format_tool_use_content(tool_use: ToolUseContent) -> str: """Format tool use content as HTML.""" # Special handling for TodoWrite @@ -540,12 +752,144 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: if tool_use.name == "Edit": return format_edit_tool_content(tool_use) + # Special handling for Multiedit + if tool_use.name == "Multiedit": + return format_multiedit_tool_content(tool_use) + + # Special handling for Read + if tool_use.name == "Read": + return format_read_tool_content(tool_use) + + # Special handling for Write + if tool_use.name == "Write": + return format_write_tool_content(tool_use) + # Default: render as key/value table using shared renderer return render_params_table(tool_use.input) -def format_tool_result_content(tool_result: ToolResultContent) -> str: - """Format tool result content as HTML, including images.""" +def _parse_cat_n_snippet( + lines: List[str], start_idx: int = 0 +) -> Optional[tuple[str, Optional[str], int]]: + """Parse cat-n formatted snippet from lines. + + Args: + lines: List of lines to parse + start_idx: Index to start parsing from (default: 0) + + Returns: + Tuple of (code_content, system_reminder, line_offset) or None if not parseable + """ + import re + + code_lines: List[str] = [] + system_reminder: Optional[str] = None + in_system_reminder = False + line_offset = 1 # Default offset + + for line in lines[start_idx:]: + # Check for system-reminder start + if "" in line: + in_system_reminder = True + system_reminder = "" + continue + + # Check for system-reminder end + if "" in line: + in_system_reminder = False + continue + + # If in system reminder, accumulate reminder text + if in_system_reminder: + if system_reminder is not None: + system_reminder += line + "\n" + continue + + # Parse regular code line (format: " 123β†’content") + match = re.match(r"\s+(\d+)β†’(.*)$", line) + if match: + line_num = int(match.group(1)) + # Capture the first line number as offset + if not code_lines: + line_offset = line_num + code_lines.append(match.group(2)) + elif line.strip() == "": # Allow empty lines between cat-n lines + continue + else: # Non-matching non-empty line, stop parsing + break + + if not code_lines: + return None + + return ( + "\n".join(code_lines), + system_reminder.strip() if system_reminder else None, + line_offset, + ) + + +def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str], int]]: + """Parse Read tool result in cat-n format. + + Returns: + Tuple of (code_content, system_reminder, line_offset) or None if not parseable + """ + import re + + # Check if content matches the cat-n format pattern (line_number β†’ content) + lines = content.split("\n") + if not lines or not re.match(r"\s+\d+β†’", lines[0]): + return None + + return _parse_cat_n_snippet(lines) + + +def _parse_edit_tool_result(content: str) -> Optional[tuple[str, int]]: + """Parse Edit tool result to extract code snippet. + + Edit tool results typically have format: + "The file ... has been updated. Here's the result of running `cat -n` on a snippet..." + followed by cat-n formatted lines. + + Returns: + Tuple of (code_content, line_offset) or None if not parseable + """ + import re + + # Look for the cat-n snippet after the preamble + # Pattern: look for first line that matches the cat-n format + lines = content.split("\n") + code_start_idx = None + + for i, line in enumerate(lines): + if re.match(r"\s+\d+β†’", line): + code_start_idx = i + break + + if code_start_idx is None: + return None + + result = _parse_cat_n_snippet(lines, code_start_idx) + if result is None: + return None + + code_content, _system_reminder, line_offset = result + # Edit tool doesn't use system_reminder, so we just return code and offset + return (code_content, line_offset) + + +def format_tool_result_content( + tool_result: ToolResultContent, + file_path: Optional[str] = None, + tool_name: Optional[str] = None, +) -> str: + """Format tool result content as HTML, including images. + + Args: + tool_result: The tool result content + file_path: Optional file path for context (used for Read/Edit/Write tool rendering) + tool_name: Optional tool name for specialized rendering (e.g., "Write", "Read", "Edit") + """ # Handle both string and structured content if isinstance(tool_result.content, str): raw_content = tool_result.content @@ -577,6 +921,115 @@ def format_tool_result_content(tool_result: ToolResultContent) -> str: raw_content = "\n".join(content_parts) has_images = len(image_html_parts) > 0 + # Strip XML tags but keep the content inside + # Also strip redundant "String: ..." portions that echo the input + import re + + if raw_content: + # Remove ... tags but keep inner content + raw_content = re.sub( + r"(.*?)", + r"\1", + raw_content, + flags=re.DOTALL, + ) + # Remove "String: ..." portions that echo the input (everything after "String:" to end) + raw_content = re.sub(r"\nString:.*$", "", raw_content, flags=re.DOTALL) + + # Special handling for Write tool: only show first line (acknowledgment) on success + if tool_name == "Write" and not tool_result.is_error and not has_images: + lines = raw_content.split("\n") + if lines: + # Keep only the first acknowledgment line and add ellipsis + first_line = lines[0] + escaped_html = escape_html(first_line) + return f"
{escaped_html} ...
" + + # Try to parse as Read tool result if file_path is provided + if file_path and tool_name == "Read" and not has_images: + parsed_result = _parse_read_tool_result(raw_content) + if parsed_result: + code_content, system_reminder, line_offset = parsed_result + + # Highlight code with Pygments using correct line offset + highlighted_html = _highlight_code_with_pygments( + code_content, file_path, linenostart=line_offset + ) + + # Build result HTML + result_parts = ["
"] + + # Make collapsible if content has more than 12 lines + lines = code_content.split("\n") + if len(lines) > 12: + # Get preview (first ~5 lines) + preview_lines = lines[:5] + preview_html = _highlight_code_with_pygments( + "\n".join(preview_lines), file_path, linenostart=line_offset + ) + + result_parts.append(f""" +
+ + {len(lines)} lines +
{preview_html}
+
+
{highlighted_html}
+
+ """) + else: + # Show directly without collapsible + result_parts.append(highlighted_html) + + # Add system reminder if present (after code, always visible) + if system_reminder: + escaped_reminder = escape_html(system_reminder) + result_parts.append( + f"
πŸ€– {escaped_reminder}
" + ) + + result_parts.append("
") + return "".join(result_parts) + + # Try to parse as Edit tool result if file_path is provided + if file_path and tool_name == "Edit" and not has_images: + parsed_result = _parse_edit_tool_result(raw_content) + if parsed_result: + parsed_code, line_offset = parsed_result + + # Highlight code with Pygments using correct line offset + highlighted_html = _highlight_code_with_pygments( + parsed_code, file_path, linenostart=line_offset + ) + + # Build result HTML + result_parts = ["
"] + + # Make collapsible if content has more than 12 lines + lines = parsed_code.split("\n") + if len(lines) > 12: + # Get preview (first ~5 lines) + preview_lines = lines[:5] + preview_html = _highlight_code_with_pygments( + "\n".join(preview_lines), file_path, linenostart=line_offset + ) + + result_parts.append(f""" +
+ + {len(lines)} lines +
{preview_html}
+
+
{highlighted_html}
+
+ """) + else: + # Show directly without collapsible + result_parts.append(highlighted_html) + + result_parts.append("
") + return "".join(result_parts) + # Check if this looks like Bash tool output and process ANSI codes # Bash tool results often contain ANSI escape sequences and terminal output if _looks_like_bash_output(raw_content): @@ -592,11 +1045,11 @@ def format_tool_result_content(tool_result: ToolResultContent) -> str: combined_content = f"{text_html}{images_html}" # Always make collapsible when images are present - preview_text = "Text and image content (click to expand)" + preview_text = "Text and image content" return f"""
-
{preview_text}
+ {preview_text}
{combined_content} @@ -928,10 +1381,13 @@ def render_message_content(content: List[ContentItem], message_type: str) -> str def _get_template_environment() -> Environment: """Get Jinja2 template environment.""" templates_dir = Path(__file__).parent / "templates" - return Environment( + env = Environment( loader=FileSystemLoader(templates_dir), autoescape=select_autoescape(["html", "xml"]), ) + # Add custom filters/functions + env.globals["starts_with_emoji"] = starts_with_emoji # type: ignore[index] + return env class TemplateMessage: @@ -950,13 +1406,18 @@ def __init__( token_usage: Optional[str] = None, tool_use_id: Optional[str] = None, title_hint: Optional[str] = None, + has_markdown: bool = False, + message_title: Optional[str] = None, ): self.type = message_type self.content_html = content_html self.formatted_timestamp = formatted_timestamp self.css_class = css_class self.raw_timestamp = raw_timestamp - self.display_type = message_type.title() + # Display title for message header (capitalized, with decorations) + self.message_title = ( + message_title if message_title is not None else message_type.title() + ) self.session_summary = session_summary self.session_id = session_id self.is_session_header = is_session_header @@ -964,9 +1425,11 @@ def __init__( self.token_usage = token_usage self.tool_use_id = tool_use_id self.title_hint = title_hint + self.has_markdown = has_markdown # Pairing metadata self.is_paired = False self.pair_role: Optional[str] = None # "pair_first", "pair_last", "pair_middle" + self.pair_duration: Optional[str] = None # Duration for pair_last messages class TemplateProject: @@ -1371,8 +1834,8 @@ def _convert_ansi_to_html(text: str) -> str: # return css_class, content_html, message_type -def _process_command_message(text_content: str) -> tuple[str, str, str]: - """Process a command message and return (css_class, content_html, message_type).""" +def _process_command_message(text_content: str) -> tuple[str, str, str, str]: + """Process a command message and return (css_class, content_html, message_type, message_title).""" css_class = "system" command_name, command_args, command_contents = extract_command_info(text_content) escaped_command_name = escape_html(command_name) @@ -1392,11 +1855,12 @@ def _process_command_message(text_content: str) -> tuple[str, str, str]: content_html = "
".join(content_parts) message_type = "system" - return css_class, content_html, message_type + message_title = "System" + return css_class, content_html, message_type, message_title -def _process_local_command_output(text_content: str) -> tuple[str, str, str]: - """Process local command output and return (css_class, content_html, message_type).""" +def _process_local_command_output(text_content: str) -> tuple[str, str, str, str]: + """Process local command output and return (css_class, content_html, message_type, message_title).""" import re css_class = "system command-output" @@ -1433,11 +1897,12 @@ def _process_local_command_output(text_content: str) -> tuple[str, str, str]: content_html = escape_html(text_content) message_type = "system" - return css_class, content_html, message_type + message_title = "System" + return css_class, content_html, message_type, message_title -def _process_bash_input(text_content: str) -> tuple[str, str, str]: - """Process bash input command and return (css_class, content_html, message_type).""" +def _process_bash_input(text_content: str) -> tuple[str, str, str, str]: + """Process bash input command and return (css_class, content_html, message_type, message_title).""" import re css_class = "bash-input" @@ -1458,11 +1923,12 @@ def _process_bash_input(text_content: str) -> tuple[str, str, str]: content_html = escape_html(text_content) message_type = "bash" - return css_class, content_html, message_type + message_title = "Bash" + return css_class, content_html, message_type, message_title -def _process_bash_output(text_content: str) -> tuple[str, str, str]: - """Process bash output and return (css_class, content_html, message_type).""" +def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: + """Process bash output and return (css_class, content_html, message_type, message_title).""" import re css_class = "bash-output" @@ -1500,16 +1966,18 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str]: ) message_type = "bash" - return css_class, content_html, message_type + message_title = "Bash" + return css_class, content_html, message_type, message_title def _process_regular_message( text_only_content: List[ContentItem], message_type: str, is_sidechain: bool, -) -> tuple[str, str, str]: - """Process regular message and return (css_class, content_html, message_type).""" +) -> tuple[str, str, str, str]: + """Process regular message and return (css_class, content_html, message_type, message_title).""" css_class = f"{message_type}" + message_title = message_type.title() # Default title # Handle user-specific preprocessing if message_type == "user": @@ -1521,7 +1989,7 @@ def _process_regular_message( content_html, is_compacted = render_user_message_content(text_only_content) if is_compacted: css_class = f"{message_type} compacted" - message_type = "πŸ€– User (compacted conversation)" + message_title = "User (compacted conversation)" else: # Non-user messages: render directly content_html = render_message_content(text_only_content, message_type) @@ -1529,15 +1997,15 @@ def _process_regular_message( if is_sidechain: css_class = f"{css_class} sidechain" - # Update message type for display - if not is_compacted: # Don't override compacted message type - message_type = ( + # Update message title for display + if not is_compacted: # Don't override compacted message title + message_title = ( "πŸ“ Sub-assistant prompt" if message_type == "user" else "πŸ”— Sub-assistant" ) - return css_class, content_html, message_type + return css_class, content_html, message_type, message_title def _get_combined_transcript_link(cache_manager: "CacheManager") -> Optional[str]: @@ -1555,7 +2023,24 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: """Identify and mark paired messages (e.g., command + output, tool use + result). Modifies messages in-place by setting is_paired and pair_role fields. + + Uses a two-pass algorithm: + 1. First pass: Build index of tool_use_id -> message index for tool_use and tool_result + 2. Second pass: Sequential scan for adjacent pairs (system+output, bash, thinking+assistant) + and match tool_use/tool_result using the index """ + # Pass 1: Build index of tool_use messages and tool_result messages by tool_use_id + tool_use_index: Dict[str, int] = {} # tool_use_id -> message index + tool_result_index: Dict[str, int] = {} # tool_use_id -> message index + + for i, msg in enumerate(messages): + if msg.tool_use_id: + if "tool_use" in msg.css_class: + tool_use_index[msg.tool_use_id] = i + elif "tool_result" in msg.css_class: + tool_result_index[msg.tool_use_id] = i + + # Pass 2: Sequential scan to identify pairs i = 0 while i < len(messages): current = messages[i] @@ -1565,7 +2050,7 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 1 continue - # Check for system command + command output pair + # Check for system command + command output pair (adjacent only) if current.css_class == "system" and i + 1 < len(messages): next_msg = messages[i + 1] if "command-output" in next_msg.css_class: @@ -1576,24 +2061,17 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 2 continue - # Check for tool_use + tool_result pair (match by tool_use_id) + # Check for tool_use + tool_result pair using index (no distance limit) if "tool_use" in current.css_class and current.tool_use_id: - # Look ahead for matching tool_result - for j in range( - i + 1, min(i + 10, len(messages)) - ): # Look ahead up to 10 messages - next_msg = messages[j] - if ( - "tool_result" in next_msg.css_class - and next_msg.tool_use_id == current.tool_use_id - ): - current.is_paired = True - current.pair_role = "pair_first" - next_msg.is_paired = True - next_msg.pair_role = "pair_last" - break + if current.tool_use_id in tool_result_index: + result_idx = tool_result_index[current.tool_use_id] + result_msg = messages[result_idx] + current.is_paired = True + current.pair_role = "pair_first" + result_msg.is_paired = True + result_msg.pair_role = "pair_last" - # Check for bash-input + bash-output pair + # Check for bash-input + bash-output pair (adjacent only) if current.css_class == "bash-input" and i + 1 < len(messages): next_msg = messages[i + 1] if next_msg.css_class == "bash-output": @@ -1604,7 +2082,7 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 2 continue - # Check for thinking + assistant pair + # Check for thinking + assistant pair (adjacent only) if "thinking" in current.css_class and i + 1 < len(messages): next_msg = messages[i + 1] if "assistant" in next_msg.css_class: @@ -1618,6 +2096,75 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 1 +def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMessage]: + """Reorder messages so paired messages are adjacent while preserving chronological order. + + - Unpaired messages and first messages in pairs maintain chronological order + - Last messages in pairs are moved immediately after their first message + - Timestamps are enhanced to show duration for paired messages + + Uses dictionary-based approach to find pairs efficiently: + 1. Build index of all pair_last messages by tool_use_id + 2. Single pass through messages, inserting pair_last immediately after pair_first + """ + from datetime import datetime + + # Build index of pair_last messages by tool_use_id + pair_last_index: Dict[str, int] = {} # tool_use_id -> message index + + for i, msg in enumerate(messages): + if msg.is_paired and msg.pair_role == "pair_last" and msg.tool_use_id: + pair_last_index[msg.tool_use_id] = i + + # Create reordered list + reordered: List[TemplateMessage] = [] + skip_indices: set[int] = set() + + for i, msg in enumerate(messages): + if i in skip_indices: + continue + + reordered.append(msg) + + # If this is the first message in a pair, immediately add its pair_last + if msg.is_paired and msg.pair_role == "pair_first" and msg.tool_use_id: + if msg.tool_use_id in pair_last_index: + last_idx = pair_last_index[msg.tool_use_id] + pair_last = messages[last_idx] + reordered.append(pair_last) + skip_indices.add(last_idx) + + # Calculate duration between pair messages + try: + if msg.raw_timestamp and pair_last.raw_timestamp: + # Parse ISO timestamps + first_time = datetime.fromisoformat( + msg.raw_timestamp.replace("Z", "+00:00") + ) + last_time = datetime.fromisoformat( + pair_last.raw_timestamp.replace("Z", "+00:00") + ) + duration = last_time - first_time + + # Format duration nicely + total_seconds = duration.total_seconds() + if total_seconds < 1: + duration_str = f"took {int(total_seconds * 1000)} ms" + elif total_seconds < 60: + duration_str = f"took {total_seconds:.1f}s" + else: + minutes = int(total_seconds // 60) + seconds = int(total_seconds % 60) + duration_str = f"took {minutes}m {seconds}s" + + # Store duration in pair_last for template rendering + pair_last.pair_duration = duration_str + except (ValueError, AttributeError): + pass + + return reordered + + def generate_session_html( messages: List[TranscriptEntry], session_id: str, @@ -1661,6 +2208,86 @@ def generate_html( if not title: title = "Claude Transcript" + # Deduplicate messages caused by Claude Code version upgrade during session + # Only deduplicate when same message.id appears with DIFFERENT versions + # Streaming fragments (same message.id, same version) are kept as separate messages + from claude_code_log.models import AssistantTranscriptEntry, UserTranscriptEntry + from packaging.version import parse as parse_version + from collections import defaultdict + + # Group messages by their unique identifier + message_groups: Dict[str, List[tuple[int, str, TranscriptEntry]]] = defaultdict( + list + ) + + for idx, message in enumerate(messages): + unique_id = None + version_str = getattr(message, "version", "0.0.0") + + # Determine unique identifier based on message type + if isinstance(message, AssistantTranscriptEntry): + # Assistant messages: use message.id + if hasattr(message.message, "id"): + unique_id = f"msg:{message.message.id}" # type: ignore + + elif isinstance(message, UserTranscriptEntry): + # User messages (tool results): use tool_use_id + if hasattr(message, "message") and message.message.content: + for item in message.message.content: + if hasattr(item, "tool_use_id"): + unique_id = f"tool:{item.tool_use_id}" # type: ignore + break + + if unique_id: + message_groups[unique_id].append((idx, version_str, message)) + + # Determine which indices to keep + indices_to_keep: set[int] = set() + + for unique_id, group in message_groups.items(): + if len(group) == 1: + # Single message, always keep + indices_to_keep.add(group[0][0]) + else: + # Multiple messages with same ID - check if they have different versions + versions = {version_str for _, version_str, _ in group} + + if len(versions) == 1: + # All same version = streaming fragments, keep ALL of them + for idx, _, _ in group: + indices_to_keep.add(idx) + else: + # Different versions = version duplicates, keep only highest version + try: + # Sort by semantic version, keep highest + sorted_group = sorted( + group, key=lambda x: parse_version(x[1]), reverse=True + ) + indices_to_keep.add(sorted_group[0][0]) + except Exception: + # If version parsing fails, keep first occurrence + indices_to_keep.add(group[0][0]) + + # Build deduplicated list + deduplicated_messages: List[TranscriptEntry] = [] + + for idx, message in enumerate(messages): + # Check if this message has a unique ID + has_unique_id = False + if isinstance(message, AssistantTranscriptEntry): + has_unique_id = hasattr(message.message, "id") + elif isinstance(message, UserTranscriptEntry): + if hasattr(message, "message") and message.message.content: + has_unique_id = any( + hasattr(item, "tool_use_id") for item in message.message.content + ) + + # Keep message if: no unique ID (e.g., queue-operation) OR in keep set + if not has_unique_id or idx in indices_to_keep: + deduplicated_messages.append(message) + + messages = deduplicated_messages + # Pre-process to find and attach session summaries session_summaries: Dict[str, str] = {} uuid_to_session: Dict[str, str] = {} @@ -1708,6 +2335,26 @@ def generate_html( # Track which messages should show token usage (first occurrence of each requestId) show_tokens_for_message: set[str] = set() + # Build mapping of tool_use_id to tool info for specialized tool result rendering + tool_use_context: Dict[str, Dict[str, Any]] = {} + for message in messages: + if hasattr(message, "message") and hasattr(message.message, "content"): # type: ignore + content = message.message.content # type: ignore + if isinstance(content, list): + for item in content: # type: ignore[reportUnknownVariableType] + # Check if it's a tool_use item + if hasattr(item, "type") and hasattr(item, "id"): # type: ignore[reportUnknownArgumentType] + item_type = getattr(item, "type", None) # type: ignore[reportUnknownArgumentType] + if item_type == "tool_use": + tool_id = getattr(item, "id", "") # type: ignore[reportUnknownArgumentType] + tool_name = getattr(item, "name", "") # type: ignore[reportUnknownArgumentType] + tool_input = getattr(item, "input", {}) # type: ignore[reportUnknownArgumentType] + if tool_id: + tool_use_context[tool_id] = { + "name": tool_name, + "input": tool_input, + } + # Process messages into template-friendly format template_messages: List[TemplateMessage] = [] @@ -1734,12 +2381,13 @@ def generate_html( content_html = f"{level_icon} {html_content}" system_template_message = TemplateMessage( - message_type=f"System {level.title()}", + message_type="system", content_html=content_html, formatted_timestamp=formatted_timestamp, css_class=level_css, raw_timestamp=timestamp, session_id=session_id, + message_title=f"System {level.title()}", ) template_messages.append(system_template_message) continue @@ -1923,20 +2571,28 @@ def generate_html( # Determine CSS class and content based on message type and duplicate status if is_command: - css_class, content_html, message_type = _process_command_message( - text_content + css_class, content_html, message_type, message_title = ( + _process_command_message(text_content) ) elif is_local_output: - css_class, content_html, message_type = _process_local_command_output( - text_content + css_class, content_html, message_type, message_title = ( + _process_local_command_output(text_content) ) elif is_bash_cmd: - css_class, content_html, message_type = _process_bash_input(text_content) + css_class, content_html, message_type, message_title = _process_bash_input( + text_content + ) elif is_bash_result: - css_class, content_html, message_type = _process_bash_output(text_content) + css_class, content_html, message_type, message_title = _process_bash_output( + text_content + ) else: - css_class, content_html, message_type = _process_regular_message( - text_only_content, message_type, getattr(message, "isSidechain", False) + css_class, content_html, message_type, message_title = ( + _process_regular_message( + text_only_content, + message_type, + getattr(message, "isSidechain", False), + ) ) # Create main message (if it has text content) @@ -1950,6 +2606,7 @@ def generate_html( session_summary=session_summary, session_id=session_id, token_usage=token_usage_str, + message_title=message_title, ) template_messages.append(template_message) @@ -1982,11 +2639,34 @@ def generate_html( escaped_id = escape_html(tool_use_converted.id) item_tool_use_id = tool_use_converted.id tool_title_hint = f"ID: {escaped_id}" - # Use simplified display names without "Tool Use:" prefix + + # Get summary for header (description or filepath) + summary = get_tool_summary(tool_use_converted) + + # Set message_type (for CSS/logic) and message_title (for display) + tool_message_type = "tool_use" if tool_use_converted.name == "TodoWrite": - tool_message_type = "πŸ“ Todo List" + tool_message_title = "πŸ“ Todo List" + elif tool_use_converted.name in ("Edit", "Write"): + # Use πŸ“ icon for Edit/Write + if summary: + escaped_summary = escape_html(summary) + tool_message_title = f"πŸ“ {escaped_name} {escaped_summary}" + else: + tool_message_title = f"πŸ“ {escaped_name}" + elif tool_use_converted.name == "Read": + # Use πŸ“„ icon for Read + if summary: + escaped_summary = escape_html(summary) + tool_message_title = f"πŸ“„ {escaped_name} {escaped_summary}" + else: + tool_message_title = f"πŸ“„ {escaped_name}" + elif summary: + # For other tools (like Bash), append summary + escaped_summary = escape_html(summary) + tool_message_title = f"{escaped_name} {escaped_summary}" else: - tool_message_type = escaped_name + tool_message_title = escaped_name tool_css_class = "tool_use" elif isinstance(tool_item, ToolResultContent) or item_type == "tool_result": # Convert Anthropic type to our format if necessary @@ -2000,13 +2680,30 @@ def generate_html( else: tool_result_converted = tool_item - tool_content_html = format_tool_result_content(tool_result_converted) + # Get file_path and tool_name from tool_use context for specialized rendering + result_file_path: Optional[str] = None + result_tool_name: Optional[str] = None + if tool_result_converted.tool_use_id in tool_use_context: + tool_ctx = tool_use_context[tool_result_converted.tool_use_id] + result_tool_name = tool_ctx.get("name") + if result_tool_name in ( + "Read", + "Edit", + "Write", + ) and "file_path" in tool_ctx.get("input", {}): + result_file_path = tool_ctx["input"]["file_path"] + + tool_content_html = format_tool_result_content( + tool_result_converted, result_file_path, result_tool_name + ) escaped_id = escape_html(tool_result_converted.tool_use_id) item_tool_use_id = tool_result_converted.tool_use_id tool_title_hint = f"ID: {escaped_id}" # Simplified: no "Tool Result" heading, just show error indicator if present - error_indicator = "🚨 Error" if tool_result_converted.is_error else "" - tool_message_type = error_indicator if error_indicator else "" + tool_message_type = "tool_result" + tool_message_title = ( + "🚨 Error" if tool_result_converted.is_error else "" + ) tool_css_class = ( "tool_result error" if tool_result_converted.is_error @@ -2023,7 +2720,8 @@ def generate_html( thinking_converted = tool_item tool_content_html = format_thinking_content(thinking_converted) - tool_message_type = "Thinking" + tool_message_type = "thinking" + tool_message_title = "Thinking" tool_css_class = "thinking" elif isinstance(tool_item, ImageContent) or item_type == "image": # Convert Anthropic type to our format if necessary @@ -2032,14 +2730,16 @@ def generate_html( continue else: tool_content_html = format_image_content(tool_item) - tool_message_type = "Image" + tool_message_type = "image" + tool_message_title = "Image" tool_css_class = "image" else: # Handle unknown content types tool_content_html = ( f"

Unknown content type: {escape_html(str(type(tool_item)))}

" ) - tool_message_type = "Unknown Content" + tool_message_type = "unknown" + tool_message_title = "Unknown Content" tool_css_class = "unknown" # Preserve sidechain context for tool/thinking/image content within sidechain messages @@ -2056,6 +2756,7 @@ def generate_html( session_id=session_id, tool_use_id=item_tool_use_id, title_hint=tool_title_hint, + message_title=tool_message_title, ) template_messages.append(tool_template_message) @@ -2115,6 +2816,9 @@ def generate_html( # Identify and mark paired messages (command+output, tool_use+tool_result, etc.) _identify_message_pairs(template_messages) + # Reorder messages so pairs are adjacent while preserving chronological order + template_messages = _reorder_paired_messages(template_messages) + # Render template env = _get_template_environment() template = env.get_template("transcript.html") diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index 77442f46..5ee38644 100644 --- a/claude_code_log/templates/components/global_styles.css +++ b/claude_code_log/templates/components/global_styles.css @@ -56,6 +56,16 @@ h1 { } /* Common typography */ + +code { + background-color: var(--code-bg-color); + padding: 2px 4px; + border-radius: 3px; + font-family: var(--font-monospace); + line-height: 1.5; +} + + pre { background-color: #12121212; padding: 10px; @@ -96,6 +106,22 @@ pre { gap: 8px; } +.header > span:first-child { + flex: 1; + min-width: 0; + overflow: hidden; +} + +/* Tool summary in header */ +.tool-summary { + font-weight: normal; + color: var(--text-muted); + font-size: 0.95em; + word-break: break-all; + overflow-wrap: anywhere; + display: inline; +} + /* Timestamps */ .timestamp { font-size: 0.85em; diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index 8bd2502c..e0bfd672 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -12,6 +12,32 @@ border-right: #00000017 1px solid; } +/* Message header info styling */ +.header-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.timestamp-row { + display: flex; + flex-direction: row; + align-items: normal; + gap: 8px; +} + +.pair-duration { + font-size: 80%; + color: #666; + font-style: italic; +} + +.token-usage { + font-size: 0.75em; + color: #888; +} + /* Paired message styling */ .message.paired-message { margin-bottom: 0; @@ -178,8 +204,8 @@ line-height: 1.4; } -.bash-tool-command { - background-color: #f8f9fa; +.content pre.bash-tool-command { + background-color: var(--code-bg-color); padding: 8px 12px; border-radius: 4px; border: 1px solid var(--tool-param-sep-color); @@ -205,7 +231,7 @@ background-color: var(--error-semi); } -.message.tool_result pre { +.message.tool_use pre, .message.tool_result pre { font-size: 80%; } @@ -280,7 +306,7 @@ padding: 8px 12px; margin: 8px 0; border-radius: 4px; - font-size: 0.9em; + font-size: 80%; font-style: italic; } @@ -339,10 +365,6 @@ pre > code { display: block; } -code { - background-color: var(--code-bg-color); -} - /* Tool content styling */ .tool-content { background-color: var(--neutral-dimmed); @@ -476,13 +498,37 @@ details summary { cursor: pointer; } +/* Collapsible code blocks (Read/Edit/Write tools) */ +.tool_result .collapsible-code { + margin-top: -2.5em; +} + +.tool_result .collapsible-code summary { + cursor: pointer; + padding-top: 0.5em; + background: var(--color-bg-secondary); + border-radius: 4px; +} + +.tool_result .collapsible-code summary:hover { + background: var(--color-bg-tertiary); +} + /* Preview content styling - shown when closed */ .collapsible-details:not([open]) .preview-content { margin-top: 4px; } -/* Hide preview content when details is open */ -.collapsible-details[open] .preview-content { +/* Tool result preview content with gradient fade */ +.tool_result .preview-content { + opacity: 0.7; + mask-image: linear-gradient(to bottom, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); +} + +/* Hide preview content when details/collapsible is open */ +.collapsible-details[open] .preview-content, +.collapsible-code[open] .preview-content { display: none; } diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css new file mode 100644 index 00000000..eb1fb407 --- /dev/null +++ b/claude_code_log/templates/components/pygments_styles.css @@ -0,0 +1,218 @@ +/* Pygments syntax highlighting styles */ + +/* Base styles for highlighted code blocks */ +.highlight { + background: var(--color-bg-secondary); + border-radius: 4px; + overflow-x: auto; +} + +.highlight pre { + margin: 0; + padding: 0.5em; + line-height: 1.5; + font-family: var(--font-monospace); + white-space: pre; /* Prevent line wrapping */ +} + +/* Smaller font for code blocks in markdown content (assistant messages, thinking, etc.) */ +.content.markdown .highlight pre code { + font-size: 80%; +} + +/* Line numbers table styling */ +.highlight .highlighttable { + width: 100%; + border-spacing: 0; + background: transparent; +} + +.highlight .highlighttable td { + padding: 0; + vertical-align: top; +} + +.highlight .highlighttable td.linenos { + width: 1%; + text-align: right; + user-select: none; + border-right: 1px solid var(--color-border-dim); +} + +.highlight .linenos pre { + color: var(--color-text-dim); + background: var(--color-bg-tertiary); + padding: 0.65em 0.5em 0.5em 0.8em; + margin: 0; + line-height: 1.5; + white-space: pre; +} + +.highlight .linenos .normal { + color: inherit; +} + +.highlight td.code { + width: 99%; +} + +.highlight td.code pre { + padding-left: 0.8em; + line-height: 1.5; +} + +/* Read tool specific styles */ +.read-tool-content { + font-weight: 600; + color: var(--color-blue); + margin: 0.5em 0; + font-family: var(--font-monospace); + font-size: 0.9em; +} + +.read-tool-result { + margin: 0.5em 0; +} + +/* Unified styling for inline preview text in tool results */ +.tool-result .line-count, +.tool-result .preview-text { + font-size: 0.9em; + color: var(--color-text-secondary); + margin-left: 0.5em; +} + +.read-tool-result .system-reminder { + margin-top: 0.5em; + padding: 0.5em; + background: var(--color-bg-secondary); + border-left: 3px solid var(--color-blue); + border-radius: 4px; + font-style: italic; + font-size: 80%; + color: var(--color-text-secondary); +} + +/* Edit tool result specific styles */ +.edit-tool-result { + margin: 0.5em 0; +} + +/* Multiedit tool specific styles */ +.multiedit-tool-content { + margin: 0.5em 0; +} + +.multiedit-file-path { + font-weight: 600; + color: var(--color-purple); + margin-bottom: 0.5em; + font-family: var(--font-monospace); + font-size: 0.9em; +} + +.multiedit-count { + font-size: 0.85em; + color: var(--color-text-secondary); + margin-bottom: 0.8em; +} + +.multiedit-item { + margin-bottom: 1em; + border-left: 2px solid var(--color-border-dim); + padding-left: 1em; +} + +.multiedit-item-header { + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 0.5em; + font-size: 0.9em; +} + +/* Write tool specific styles */ +.write-tool-content { + margin: 0.5em 0; +} + +.write-file-path { + font-weight: 600; + color: var(--color-green); + margin-bottom: 0.5em; + font-family: var(--font-monospace); + font-size: 0.9em; + padding-left: 1.5em; +} + + +/* Pygments token styles (based on 'default' theme) */ +.highlight { background: var(--color-bg-secondary); } +.highlight .hll { background-color: #ffffcc } +.highlight .c { color: #3D7B7B; font-style: italic } /* Comment */ +.highlight .err { border: 1px solid #FF0000 } /* Error */ +.highlight .k { color: #008000; font-weight: bold } /* Keyword */ +.highlight .o { color: #666666 } /* Operator */ +.highlight .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.highlight .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.highlight .cp { color: #9C6500 } /* Comment.Preproc */ +.highlight .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.highlight .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.highlight .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.highlight .gd { color: #A00000 } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.highlight .gr { color: #E40000 } /* Generic.Error */ +.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.highlight .gi { color: #008400 } /* Generic.Inserted */ +.highlight .go { color: #717171 } /* Generic.Output */ +.highlight .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.highlight .gt { color: #0044DD } /* Generic.Traceback */ +.highlight .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.highlight .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.highlight .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.highlight .kp { color: #008000 } /* Keyword.Pseudo */ +.highlight .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.highlight .kt { color: #B00040 } /* Keyword.Type */ +.highlight .m { color: #666666 } /* Literal.Number */ +.highlight .s { color: #BA2121 } /* Literal.String */ +.highlight .na { color: #687822 } /* Name.Attribute */ +.highlight .nb { color: #008000 } /* Name.Builtin */ +.highlight .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.highlight .no { color: #880000 } /* Name.Constant */ +.highlight .nd { color: #AA22FF } /* Name.Decorator */ +.highlight .ni { color: #717171; font-weight: bold } /* Name.Entity */ +.highlight .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.highlight .nf { color: #0000FF } /* Name.Function */ +.highlight .nl { color: #767600 } /* Name.Label */ +.highlight .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.highlight .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.highlight .nv { color: #19177C } /* Name.Variable */ +.highlight .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.highlight .w { color: #bbbbbb } /* Text.Whitespace */ +.highlight .mb { color: #666666 } /* Literal.Number.Bin */ +.highlight .mf { color: #666666 } /* Literal.Number.Float */ +.highlight .mh { color: #666666 } /* Literal.Number.Hex */ +.highlight .mi { color: #666666 } /* Literal.Number.Integer */ +.highlight .mo { color: #666666 } /* Literal.Number.Oct */ +.highlight .sa { color: #BA2121 } /* Literal.String.Affix */ +.highlight .sb { color: #BA2121 } /* Literal.String.Backtick */ +.highlight .sc { color: #BA2121 } /* Literal.String.Char */ +.highlight .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.highlight .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.highlight .s2 { color: #BA2121 } /* Literal.String.Double */ +.highlight .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.highlight .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.highlight .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.highlight .sx { color: #008000 } /* Literal.String.Other */ +.highlight .sr { color: #A45A77 } /* Literal.String.Regex */ +.highlight .s1 { color: #BA2121 } /* Literal.String.Single */ +.highlight .ss { color: #19177C } /* Literal.String.Symbol */ +.highlight .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.highlight .fm { color: #0000FF } /* Name.Function.Magic */ +.highlight .vc { color: #19177C } /* Name.Variable.Class */ +.highlight .vg { color: #19177C } /* Name.Variable.Global */ +.highlight .vi { color: #19177C } /* Name.Variable.Instance */ +.highlight .vm { color: #19177C } /* Name.Variable.Magic */ +.highlight .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/claude_code_log/templates/components/timezone_converter.js b/claude_code_log/templates/components/timezone_converter.js index 08bd2fa9..30d4e700 100644 --- a/claude_code_log/templates/components/timezone_converter.js +++ b/claude_code_log/templates/components/timezone_converter.js @@ -46,6 +46,7 @@ const element = timestampElements[i]; const rawTimestamp = element.getAttribute('data-timestamp'); const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); if (!rawTimestamp) continue; @@ -70,7 +71,7 @@ // Update the element with range if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd + ' | Local: ' + localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; @@ -81,11 +82,11 @@ // Single timestamp if (localTime !== utcTime) { element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' | Local: ' + localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; } else { // If they're the same (user is in UTC), just show UTC element.innerHTML = utcTime + ' (UTC)'; - element.title = 'UTC: ' + utcTime; + element.title = duration ? duration : 'UTC: ' + utcTime; } } diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 2bd92d33..31186794 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -16,6 +16,7 @@ {% include 'components/timeline_styles.css' %} {% include 'components/search_styles.css' %} {% include 'components/edit_diff_styles.css' %} +{% include 'components/pygments_styles.css' %} @@ -80,20 +81,27 @@

πŸ” Search & Filter

{% else %} + {% set markdown = message.css_class in ['assistant', 'thinking', 'sidechain'] or (message.css_class and 'compacted' in message.css_class) %}
- {% if message.display_type %}{% if message.css_class == 'user' %}🀷 {% elif message.css_class == 'assistant' %}πŸ€– {% elif - message.css_class == 'system' %}βš™οΈ {% elif message.css_class == 'tool_use' %}πŸ› οΈ {% elif - message.css_class == 'tool_result' %}🧰 {% elif message.css_class == 'thinking' %}πŸ’­ {% elif - message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.display_type }}{% endif %} -
- {{ message.formatted_timestamp }} + {% if message.message_title %}{% + if message.css_class == 'user' %}🀷 {% + elif message.css_class == 'assistant' %}πŸ€– {% + elif message.css_class == 'system' %}βš™οΈ {% + elif message.css_class == 'tool_use' and not starts_with_emoji(message.message_title) %}πŸ› οΈ {% + elif message.css_class == 'tool_result' %}🧰 {% + elif message.css_class == 'thinking' %}πŸ’­ {% + elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.message_title | safe }}{% endif %} +
+
+ {{ message.formatted_timestamp }} +
{% if message.token_usage %} - {{ message.token_usage }} + {{ message.token_usage }} {% endif %}
-
{{ message.content_html | safe }}
+
{{ message.content_html | safe }}
{% endif %} {% endfor %} diff --git a/pyproject.toml b/pyproject.toml index 82c738fb..1af5d8c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "textual>=4.0.0", "packaging>=25.0", "gitpython>=3.1.45", + "pygments>=2.19.1", ] [project.urls] diff --git a/test/test_template_data.py b/test/test_template_data.py index 6ee0dcfc..b578fc80 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -31,10 +31,10 @@ def test_template_message_creation(self): assert msg.content_html == "

Test content

" assert msg.formatted_timestamp == "2025-06-14 10:00:00" assert msg.css_class == "user" - assert msg.display_type == "User" + assert msg.message_title == "User" - def test_template_message_display_type_capitalization(self): - """Test that display_type properly capitalizes message types.""" + def test_template_message_title_capitalization(self): + """Test that message_title properly capitalizes message types.""" test_cases = [ ("user", "User"), ("assistant", "Assistant"), @@ -50,7 +50,7 @@ def test_template_message_display_type_capitalization(self): css_class="class", raw_timestamp=None, ) - assert msg.display_type == expected_display + assert msg.message_title == expected_display class TestTemplateProject: diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index ac8ec4ac..933a7f5f 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -258,8 +258,7 @@ def test_todowrite_vs_regular_tool_use(self): # Edit tool should use diff formatting (not table) assert "edit-diff" in regular_html - assert "edit-file-path" in regular_html - assert "/tmp/test.py" in regular_html + # File path no longer in content, moved to message header # Tool name/ID no longer in content, moved to message header # TodoWrite should use special formatting diff --git a/test/test_tool_result_image_rendering.py b/test/test_tool_result_image_rendering.py index 83047748..755a979f 100644 --- a/test/test_tool_result_image_rendering.py +++ b/test/test_tool_result_image_rendering.py @@ -32,7 +32,7 @@ def test_tool_result_with_image(): # Should be collapsible when images are present assert '
' in html assert "" in html - assert "Text and image content (click to expand)" in html + assert "Text and image content" in html # Should contain the text assert "Screenshot captured successfully" in html @@ -71,7 +71,7 @@ def test_tool_result_with_only_image(): # Should be collapsible assert '
' in html - assert "Text and image content (click to expand)" in html + assert "Text and image content" in html # Should contain the image with JPEG media type assert f"data:image/jpeg;base64,{sample_image_data}" in html diff --git a/test/test_version_deduplication.py b/test/test_version_deduplication.py new file mode 100644 index 00000000..a19cec3a --- /dev/null +++ b/test/test_version_deduplication.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Tests for version-based deduplication during Claude Code upgrades.""" + +from datetime import datetime +from claude_code_log.models import ( + AssistantTranscriptEntry, + AssistantMessage, + UserTranscriptEntry, + UserMessage, + ToolUseContent, + ToolResultContent, +) +from claude_code_log.renderer import generate_html + + +class TestVersionDeduplication: + """Test that duplicate messages from version upgrades are deduplicated.""" + + def test_assistant_message_deduplication(self): + """Test deduplication of assistant messages by version.""" + timestamp = datetime.now().isoformat() + + # Same assistant message in two different Claude Code versions + msg_v1 = AssistantTranscriptEntry( + type="assistant", + uuid="uuid-v1", + parentUuid="parent-001", + timestamp=timestamp, + version="2.0.31", # Older version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_duplicate", + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_edit", + name="Edit", + input={ + "file_path": "/test/file.py", + "old_string": "old", + "new_string": "new", + }, + ), + ], + stop_reason="tool_use", + ), + ) + + msg_v2 = AssistantTranscriptEntry( + type="assistant", + uuid="uuid-v2", + parentUuid="parent-002", + timestamp=timestamp, + version="2.0.34", # Newer version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_duplicate", # SAME message.id + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_edit", + name="Edit", + input={ + "file_path": "/test/file.py", + "old_string": "old", + "new_string": "new", + }, + ), + ], + stop_reason="tool_use", + ), + ) + + # Test both orderings + for messages in [[msg_v1, msg_v2], [msg_v2, msg_v1]]: + html = generate_html(messages, "Version Test") + + # Should appear only once + tool_summary_count = html.count( + "/test/file.py" + ) + assert tool_summary_count == 1, ( + f"Expected 1 tool summary, got {tool_summary_count}" + ) + + def test_tool_result_deduplication(self): + """Test deduplication of tool results by version.""" + timestamp = datetime.now().isoformat() + + # Same tool result in two different Claude Code versions + result_v1 = UserTranscriptEntry( + type="user", + uuid="uuid-result-v1", + parentUuid="parent-001", + timestamp=timestamp, + version="2.0.31", # Older version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_read_test", + content="File contents here", + ) + ], + ), + ) + + result_v2 = UserTranscriptEntry( + type="user", + uuid="uuid-result-v2", + parentUuid="parent-002", + timestamp=timestamp, + version="2.0.34", # Newer version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_read_test", # SAME tool_use_id + content="File contents here", + ) + ], + ), + ) + + # Test both orderings + for messages in [[result_v1, result_v2], [result_v2, result_v1]]: + html = generate_html(messages, "Tool Result Test") + + # Should appear only once + content_count = html.count("File contents here") + assert content_count == 1, f"Expected 1 tool result, got {content_count}" + + def test_full_stutter_pair(self): + """Test complete assistant+tool_result pair deduplication.""" + timestamp = datetime.now().isoformat() + + # Version 2.0.31 pair + assist_v1 = AssistantTranscriptEntry( + type="assistant", + uuid="assist-v1", + parentUuid="parent-001", + timestamp=timestamp, + version="2.0.31", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_full_test", + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_full_test", + name="Read", + input={"file_path": "/test/data.txt"}, + ), + ], + stop_reason="tool_use", + ), + ) + + result_v1 = UserTranscriptEntry( + type="user", + uuid="result-v1", + parentUuid="assist-v1", + timestamp=timestamp, + version="2.0.31", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_full_test", + content="Data content", + ) + ], + ), + ) + + # Version 2.0.34 pair (same IDs) + assist_v2 = AssistantTranscriptEntry( + type="assistant", + uuid="assist-v2", + parentUuid="parent-002", + timestamp=timestamp, + version="2.0.34", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_full_test", # SAME + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_full_test", # SAME + name="Read", + input={"file_path": "/test/data.txt"}, + ), + ], + stop_reason="tool_use", + ), + ) + + result_v2 = UserTranscriptEntry( + type="user", + uuid="result-v2", + parentUuid="assist-v2", + timestamp=timestamp, + version="2.0.34", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_full_test", # SAME + content="Data content", + ) + ], + ), + ) + + # Combine: v1 pair, then v2 pair + messages = [assist_v1, result_v1, assist_v2, result_v2] + html = generate_html(messages, "Full Pair Test") + + # Each should appear only once + file_path_count = html.count("/test/data.txt") + assert file_path_count == 1, f"Expected 1 file path, got {file_path_count}" + + content_count = html.count("Data content") + assert content_count == 1, f"Expected 1 data content, got {content_count}" diff --git a/uv.lock b/uv.lock index 2295d9a6..c6186129 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,7 @@ dependencies = [ { name = "mistune" }, { name = "packaging" }, { name = "pydantic" }, + { name = "pygments" }, { name = "textual" }, { name = "toml" }, ] @@ -128,6 +129,7 @@ requires-dist = [ { name = "mistune", specifier = ">=3.1.3" }, { name = "packaging", specifier = ">=25.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pygments", specifier = ">=2.19.1" }, { name = "textual", specifier = ">=4.0.0" }, { name = "toml", specifier = ">=0.10.2" }, ]