diff --git a/.gitignore b/.gitignore index 160f8f7b..fa249f7a 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,8 @@ test_output test/test_data/*.html .claude-trace .specstory + +# Local configuration files +.claude/settings.local.json +.vscode/settings.json +local.ps1 diff --git a/.vscode/settings.json b/.vscode/settings.json index eb7cde4c..e7d44597 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,4 +5,5 @@ "docs/claude-code-log-transcript.html": true, "test/test_data/cache/**": true, }, + "workbench.colorTheme": "Solarized Dark", } \ No newline at end of file diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 74de37fd..80d28d82 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from typing import List, Optional, Union, Dict, Any, cast, TYPE_CHECKING +from typing import List, Optional, Dict, Any, cast, TYPE_CHECKING if TYPE_CHECKING: from .cache import CacheManager @@ -131,8 +131,13 @@ def format_timestamp(timestamp_str: str | None) -> str: def escape_html(text: str) -> str: - """Escape HTML special characters in text.""" - return html.escape(text) + """Escape HTML special characters in text. + + Also normalizes line endings (CRLF -> LF) to prevent double spacing in
 blocks.
+    """
+    # Normalize CRLF to LF to prevent double line breaks in HTML
+    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
+    return html.escape(normalized)
 
 
 def create_collapsible_details(
@@ -285,35 +290,258 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str:
     """
 
 
+def format_bash_tool_content(tool_use: ToolUseContent) -> str:
+    """Format Bash tool use content in VS Code extension style."""
+    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}
") + + # Add command in preformatted block + html_parts.append(f"
{escaped_command}
") + html_parts.append("
") + + return "".join(html_parts) + + +def render_params_table(params: Dict[str, Any]) -> str: + """Render a dictionary of parameters as an HTML table. + + Reusable for tool parameters, diagnostic objects, etc. + """ + if not params: + return "
No parameters
" + + html_parts = [""] + + for key, value in params.items(): + escaped_key = escape_html(str(key)) + + # If value is structured (dict/list), render as JSON + if isinstance(value, (dict, list)): + try: + formatted_value = json.dumps(value, indent=2) # type: ignore[arg-type] + escaped_value = escape_html(formatted_value) + + # Make long structured values collapsible + if len(formatted_value) > 200: + preview = escape_html(formatted_value[:100]) + "..." + value_html = f""" +
+ {preview} +
{escaped_value}
+
+ """ + else: + value_html = ( + f"
{escaped_value}
" + ) + except (TypeError, ValueError): + escaped_value = escape_html(str(value)) # type: ignore[arg-type] + value_html = escaped_value + else: + # Simple value, render as-is (or collapsible if long) + escaped_value = escape_html(str(value)) + + # Make long string values collapsible + if len(str(value)) > 100: + preview = escape_html(str(value)[:80]) + "..." + value_html = f""" +
+ {preview} +
{escaped_value}
+
+ """ + else: + value_html = escaped_value + + html_parts.append(f""" + + + + + """) + + html_parts.append("
{escaped_key}{value_html}
") + 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}
") + + if replace_all: + html_parts.append( + "
🔄 Replace all occurrences
" + ) + + # Split into lines for diff + old_lines = old_string.splitlines(keepends=True) + new_lines = new_string.splitlines(keepends=True) + + # Generate unified diff to identify changed lines + differ = difflib.Differ() + diff: list[str] = list(differ.compare(old_lines, new_lines)) + + html_parts.append("
") + + i = 0 + while i < len(diff): + line = diff[i] + prefix = line[0:2] + content = line[2:] + + if prefix == "- ": + # Removed line - look ahead for corresponding addition + removed_lines: list[str] = [content] + j = i + 1 + + # Collect consecutive removed lines + while j < len(diff) and diff[j].startswith("- "): + removed_lines.append(diff[j][2:]) + j += 1 + + # Skip '? ' hint lines + while j < len(diff) and diff[j].startswith("? "): + j += 1 + + # Collect consecutive added lines + added_lines: list[str] = [] + while j < len(diff) and diff[j].startswith("+ "): + added_lines.append(diff[j][2:]) + j += 1 + + # Skip '? ' hint lines + while j < len(diff) and diff[j].startswith("? "): + j += 1 + + # Generate character-level diff for paired lines + if added_lines: + for old_line, new_line in zip(removed_lines, added_lines): + html_parts.append(_render_line_diff(old_line, new_line)) + + # Handle any unpaired lines + for old_line in removed_lines[len(added_lines) :]: + escaped = escape_html(old_line.rstrip("\n")) + html_parts.append( + f"
-{escaped}
" + ) + + for new_line in added_lines[len(removed_lines) :]: + escaped = escape_html(new_line.rstrip("\n")) + html_parts.append( + f"
+{escaped}
" + ) + else: + # No corresponding addition - just removed + for old_line in removed_lines: + escaped = escape_html(old_line.rstrip("\n")) + html_parts.append( + f"
-{escaped}
" + ) + + i = j + + elif prefix == "+ ": + # Added line without corresponding removal + escaped = escape_html(content.rstrip("\n")) + html_parts.append( + f"
+{escaped}
" + ) + i += 1 + + elif prefix == "? ": + # Skip hint lines (already processed) + i += 1 + + else: + # Unchanged line - show for context + escaped = escape_html(content.rstrip("\n")) + html_parts.append( + f"
{escaped}
" + ) + i += 1 + + html_parts.append("
") + + return "".join(html_parts) + + +def _render_line_diff(old_line: str, new_line: str) -> str: + """Render a pair of changed lines with character-level highlighting.""" + import difflib + + # Use SequenceMatcher for character-level diff + sm = difflib.SequenceMatcher(None, old_line.rstrip("\n"), new_line.rstrip("\n")) + + # Build old line with highlighting + old_parts: list[str] = [] + old_parts.append( + "
-" + ) + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + chunk = old_line[i1:i2] + if tag == "equal": + old_parts.append(escape_html(chunk)) + elif tag in ("delete", "replace"): + old_parts.append( + f"{escape_html(chunk)}" + ) + old_parts.append("
") + + # Build new line with highlighting + new_parts: list[str] = [] + new_parts.append( + "
+" + ) + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + chunk = new_line[j1:j2] + if tag == "equal": + new_parts.append(escape_html(chunk)) + elif tag in ("insert", "replace"): + new_parts.append( + f"{escape_html(chunk)}" + ) + new_parts.append("
") + + return "".join(old_parts) + "".join(new_parts) + + def format_tool_use_content(tool_use: ToolUseContent) -> str: """Format tool use content as HTML.""" # Special handling for TodoWrite if tool_use.name == "TodoWrite": return format_todowrite_content(tool_use) - # Format the input parameters - try: - formatted_input = json.dumps(tool_use.input, indent=2) - escaped_input = escape_html(formatted_input) - except (TypeError, ValueError): - escaped_input = escape_html(str(tool_use.input)) + # Special handling for Bash + if tool_use.name == "Bash": + return format_bash_tool_content(tool_use) - # For simple content, show directly without collapsible wrapper - if len(escaped_input) <= 200: - return f"
{escaped_input}
" + # Special handling for Edit + if tool_use.name == "Edit": + return format_edit_tool_content(tool_use) - # For longer content, use collapsible details but no extra wrapper - preview_text = escaped_input[:200] + "..." - return f""" -
- -
{preview_text}
-
-
-
{escaped_input}
-
-
- """ + # 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: @@ -431,22 +659,26 @@ def _looks_like_bash_output(content: str) -> bool: def format_thinking_content(thinking: ThinkingContent) -> str: - """Format thinking content as HTML.""" - escaped_thinking = escape_html(thinking.thinking.strip()) + """Format thinking content as HTML with markdown rendering.""" + thinking_text = thinking.thinking.strip() + + # Render markdown to HTML + rendered_html = render_markdown(thinking_text) # For simple content, show directly without collapsible wrapper - if len(escaped_thinking) <= 200: - return f'
{escaped_thinking}
' + if len(thinking_text) <= 200: + return f'
{rendered_html}
' # For longer content, use collapsible details but no extra wrapper - preview_text = escaped_thinking[:200] + "..." + # Use plain text for preview (first 200 chars) + preview_text = escape_html(thinking_text[:200]) + "..." return f"""
{preview_text}
-
{escaped_thinking}
+
{rendered_html}
""" @@ -460,18 +692,170 @@ def format_image_content(image: ImageContent) -> str: return f'Uploaded image' -def render_message_content( - content: Union[str, List[ContentItem]], message_type: str -) -> str: - """Render message content with proper tool use and tool result formatting.""" - if isinstance(content, str): +def _is_compacted_session_summary(text: str) -> bool: + """Check if text is a compacted session summary (model-generated markdown). + + Compacted summaries are generated when a session runs out of context and + needs to be continued. They are well-formed markdown and should be rendered + as such rather than in preformatted blocks. + """ + return text.startswith( + "This session is being continued from a previous conversation that ran out of context" + ) + + +def extract_ide_notifications(text: str) -> tuple[List[str], str]: + """Extract IDE notification tags from user message text. + + Handles: + - : Simple file open notifications + - : Code selection notifications (collapsible for large selections) + - : JSON diagnostic arrays + + Returns: + A tuple of (notifications_html_list, remaining_text) + where notifications are pre-rendered HTML divs and remaining_text + is the message content with IDE tags removed. + """ + import re + + notifications: List[str] = [] + remaining_text = text + + # Pattern 1: content + ide_file_pattern = r"(.*?)" + file_matches = list(re.finditer(ide_file_pattern, remaining_text, flags=re.DOTALL)) + + for match in file_matches: + content = match.group(1).strip() + escaped_content = escape_html(content) + notification_html = f"
🤖 {escaped_content}
" + notifications.append(notification_html) + + # Remove ide_opened_file tags + remaining_text = re.sub(ide_file_pattern, "", remaining_text, flags=re.DOTALL) + + # Pattern 2: content + selection_pattern = r"(.*?)" + selection_matches = list( + re.finditer(selection_pattern, remaining_text, flags=re.DOTALL) + ) + + for match in selection_matches: + content = match.group(1).strip() + escaped_content = escape_html(content) + + # For large selections, make them collapsible + if len(content) > 200: + preview = escape_html(content[:150]) + "..." + notification_html = f""" +
+
+ 📝 {preview} +
{escaped_content}
+
+
+ """ + else: + notification_html = f"
📝 {escaped_content}
" + + notifications.append(notification_html) + + # Remove ide_selection tags + remaining_text = re.sub(selection_pattern, "", remaining_text, flags=re.DOTALL) + + # Pattern 3: JSON + hook_pattern = r"\s*(.*?)\s*" + hook_matches = list(re.finditer(hook_pattern, remaining_text, flags=re.DOTALL)) + + for match in hook_matches: + json_content = match.group(1).strip() + try: + # Parse JSON array of diagnostic objects + diagnostics: Any = json.loads(json_content) + if isinstance(diagnostics, list): + # Render each diagnostic as a table + for diagnostic in cast(List[Any], diagnostics): + if isinstance(diagnostic, dict): + # Type assertion: we've confirmed it's a dict + diagnostic_dict = cast(Dict[str, Any], diagnostic) + table_html = render_params_table(diagnostic_dict) + notification_html = ( + f"
" + f"âš ī¸ IDE Diagnostic
{table_html}" + f"
" + ) + notifications.append(notification_html) + except (json.JSONDecodeError, ValueError): + # If JSON parsing fails, render as plain text + escaped_content = escape_html(json_content[:200]) + notification_html = ( + f"
🤖 IDE Diagnostics (parse error)
" + f"
{escaped_content}...
" + ) + notifications.append(notification_html) + + # Remove hook tags + remaining_text = re.sub(hook_pattern, "", remaining_text, flags=re.DOTALL) + + return notifications, remaining_text.strip() + + +def render_user_message_content(content_list: List[ContentItem]) -> tuple[str, bool]: + """Render user message content with IDE tag extraction and compacted summary handling. + + Returns: + A tuple of (content_html, is_compacted) + """ + # Check first text item + if content_list and hasattr(content_list[0], "text"): + first_text = getattr(content_list[0], "text", "") + + # Check for compacted session summary first + if _is_compacted_session_summary(first_text): + # Render entire content as markdown for compacted summaries + # Use "assistant" to trigger markdown rendering instead of pre-formatted text + content_html = render_message_content(content_list, "assistant") + return content_html, True + + # Extract IDE notifications from first text item + ide_notifications_html, remaining_text = extract_ide_notifications(first_text) + modified_content = content_list[1:] + + # Build new content list with remaining text + if remaining_text: + # Replace first item with remaining text + modified_content = [ + TextContent(type="text", text=remaining_text) + ] + modified_content + + # Render the content + content_html = render_message_content(modified_content, "user") + + # Prepend IDE notifications + if ide_notifications_html: + content_html = "".join(ide_notifications_html) + content_html + else: + # No text in first item or empty list, render normally + content_html = render_message_content(content_list, "user") + + return content_html, False + + +def render_message_content(content: List[ContentItem], message_type: str) -> str: + """Render message content with proper tool use and tool result formatting. + + Note: This does NOT handle user-specific preprocessing like IDE tags or + compacted session summaries. Those should be handled by render_user_message_content. + """ + if len(content) == 1 and isinstance(content[0], TextContent): if message_type == "user": # User messages are shown as-is in preformatted blocks - escaped_text = escape_html(content) + escaped_text = escape_html(content[0].text) return "
" + escaped_text + "
" else: # Assistant messages get markdown rendering - return render_markdown(content) + return render_markdown(content[0].text) # content is a list of ContentItem objects rendered_parts: List[str] = [] @@ -564,6 +948,8 @@ def __init__( session_id: Optional[str] = None, is_session_header: bool = False, token_usage: Optional[str] = None, + tool_use_id: Optional[str] = None, + title_hint: Optional[str] = None, ): self.type = message_type self.content_html = content_html @@ -576,6 +962,11 @@ def __init__( self.is_session_header = is_session_header self.session_subtitle: Optional[str] = None self.token_usage = token_usage + self.tool_use_id = tool_use_id + self.title_hint = title_hint + # Pairing metadata + self.is_paired = False + self.pair_role: Optional[str] = None # "pair_first", "pair_last", "pair_middle" class TemplateProject: @@ -1017,13 +1408,27 @@ def _process_local_command_output(text_content: str) -> tuple[str, str, str]: ) if stdout_match: stdout_content = stdout_match.group(1).strip() - # Convert ANSI codes to HTML for colored display - html_content = _convert_ansi_to_html(stdout_content) - # Use
 to preserve formatting and line breaks
-        content_html = (
-            f"Command Output:
" - f"
{html_content}
" - ) + + # Check if content looks like markdown (starts with markdown headers) + is_markdown = bool(re.match(r"^#+\s+", stdout_content, re.MULTILINE)) + + if is_markdown: + # Render as markdown + import mistune + + markdown_html = mistune.html(stdout_content) + content_html = ( + f"Command Output:
" + f"
{markdown_html}
" + ) + else: + # Convert ANSI codes to HTML for colored display + html_content = _convert_ansi_to_html(stdout_content) + # Use
 to preserve formatting and line breaks
+            content_html = (
+                f"Command Output:
" + f"
{html_content}
" + ) else: content_html = escape_html(text_content) @@ -1099,20 +1504,38 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str]: def _process_regular_message( - text_only_content: Union[str, List[ContentItem]], + 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).""" css_class = f"{message_type}" - content_html = render_message_content(text_only_content, message_type) + + # Handle user-specific preprocessing + if message_type == "user": + # Sub-assistant prompts (sidechain user messages) should be rendered as markdown + if is_sidechain: + content_html = render_message_content(text_only_content, "assistant") + is_compacted = False + else: + 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)" + else: + # Non-user messages: render directly + content_html = render_message_content(text_only_content, message_type) + is_compacted = False if is_sidechain: - css_class = f"{message_type} sidechain" + css_class = f"{css_class} sidechain" # Update message type for display - message_type = ( - "📝 Sub-assistant prompt" if message_type == "user" else "🔗 Sub-assistant" - ) + if not is_compacted: # Don't override compacted message type + message_type = ( + "📝 Sub-assistant prompt" + if message_type == "user" + else "🔗 Sub-assistant" + ) return css_class, content_html, message_type @@ -1128,6 +1551,73 @@ def _get_combined_transcript_link(cache_manager: "CacheManager") -> Optional[str return None +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. + """ + i = 0 + while i < len(messages): + current = messages[i] + + # Skip session headers + if current.is_session_header: + i += 1 + continue + + # Check for system command + command output pair + if current.css_class == "system" and i + 1 < len(messages): + next_msg = messages[i + 1] + if "command-output" in next_msg.css_class: + current.is_paired = True + current.pair_role = "pair_first" + next_msg.is_paired = True + next_msg.pair_role = "pair_last" + i += 2 + continue + + # Check for tool_use + tool_result pair (match by tool_use_id) + 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 + + # Check for bash-input + bash-output pair + if current.css_class == "bash-input" and i + 1 < len(messages): + next_msg = messages[i + 1] + if next_msg.css_class == "bash-output": + current.is_paired = True + current.pair_role = "pair_first" + next_msg.is_paired = True + next_msg.pair_role = "pair_last" + i += 2 + continue + + # Check for thinking + assistant pair + if "thinking" in current.css_class and i + 1 < len(messages): + next_msg = messages[i + 1] + if "assistant" in next_msg.css_class: + current.is_paired = True + current.pair_role = "pair_first" + next_msg.is_paired = True + next_msg.pair_role = "pair_last" + i += 2 + continue + + i += 1 + + def generate_session_html( messages: List[TranscriptEntry], session_id: str, @@ -1239,8 +1729,9 @@ def generate_html( level_icon = {"warning": "âš ī¸", "error": "❌", "info": "â„šī¸"}.get(level, "â„šī¸") level_css = f"system system-{level}" - escaped_content = escape_html(message.content) - content_html = f"{level_icon} System {level.title()}: {escaped_content}" + # Process ANSI codes in system messages (they may contain command output) + html_content = _convert_ansi_to_html(message.content) + content_html = f"{level_icon} {html_content}" system_template_message = TemplateMessage( message_type=f"System {level.title()}", @@ -1260,7 +1751,7 @@ def generate_html( # Separate tool/thinking/image content from text content tool_items: List[ContentItem] = [] - text_only_content: Union[str, List[ContentItem]] = [] + text_only_content: List[ContentItem] = [] if isinstance(message_content, list): text_only_items: List[ContentItem] = [] @@ -1279,7 +1770,9 @@ def generate_html( text_only_content = text_only_items else: # Single string content - text_only_content = message_content + message_content = message_content.strip() + if message_content: + text_only_content = [TextContent(type="text", text=message_content)] # Skip if no meaningful content if not text_content.strip() and not tool_items: @@ -1447,12 +1940,7 @@ def generate_html( ) # Create main message (if it has text content) - if text_only_content and ( - isinstance(text_only_content, str) - and text_only_content.strip() - or isinstance(text_only_content, list) - and text_only_content - ): + if text_only_content: template_message = TemplateMessage( message_type=message_type, content_html=content_html, @@ -1474,6 +1962,8 @@ def generate_html( # Handle both custom types and Anthropic types item_type = getattr(tool_item, "type", None) + item_tool_use_id: Optional[str] = None + tool_title_hint: Optional[str] = None if isinstance(tool_item, ToolUseContent) or item_type == "tool_use": # Convert Anthropic type to our format if necessary @@ -1490,10 +1980,13 @@ def generate_html( tool_content_html = format_tool_use_content(tool_use_converted) escaped_name = escape_html(tool_use_converted.name) 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 if tool_use_converted.name == "TodoWrite": - tool_message_type = f"📝 Todo List (ID: {escaped_id})" + tool_message_type = "📝 Todo List" else: - tool_message_type = f"Tool Use: {escaped_name} (ID: {escaped_id})" + tool_message_type = 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 @@ -1509,11 +2002,16 @@ def generate_html( tool_content_html = format_tool_result_content(tool_result_converted) escaped_id = escape_html(tool_result_converted.tool_use_id) - error_indicator = ( - " (🚨 Error)" if tool_result_converted.is_error else "" + 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_css_class = ( + "tool_result error" + if tool_result_converted.is_error + else "tool_result" ) - tool_message_type = f"Tool Result{error_indicator}: {escaped_id}" - tool_css_class = "tool_result" elif isinstance(tool_item, ThinkingContent) or item_type == "thinking": # Convert Anthropic type to our format if necessary if not isinstance(tool_item, ThinkingContent): @@ -1556,6 +2054,8 @@ def generate_html( raw_timestamp=tool_timestamp, session_summary=session_summary, session_id=session_id, + tool_use_id=item_tool_use_id, + title_hint=tool_title_hint, ) template_messages.append(tool_template_message) @@ -1612,6 +2112,9 @@ def generate_html( } ) + # Identify and mark paired messages (command+output, tool_use+tool_result, etc.) + _identify_message_pairs(template_messages) + # Render template env = _get_template_environment() template = env.get_template("transcript.html") diff --git a/claude_code_log/templates/components/edit_diff_styles.css b/claude_code_log/templates/components/edit_diff_styles.css new file mode 100644 index 00000000..af105adb --- /dev/null +++ b/claude_code_log/templates/components/edit_diff_styles.css @@ -0,0 +1,76 @@ +/* Edit tool diff styling */ +.edit-tool-content { + margin: 8px 0; +} + +.edit-file-path { + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; + font-size: 0.95em; +} + +.edit-replace-all { + color: var(--text-muted); + font-size: 0.85em; + font-style: italic; + margin-bottom: 8px; +} + +.edit-diff { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + overflow-x: auto; + font-family: var(--font-monospace); + font-size: 80%; + line-height: 2ex; +} + +/* Diff line styling */ +.diff-line { + padding: 2px 4px 2px 2px; + white-space: pre-wrap; + word-wrap: break-word; +} + +.diff-marker { + display: inline-block; + width: 1.5em; + text-align: center; + user-select: none; + color: var(--text-muted); +} + +/* Line backgrounds */ +.diff-removed { + background-color: #ffebe9; + border-left: 3px solid #f85149; +} + +.diff-added { + background-color: #dafbe1; + border-left: 3px solid #3fb950; +} + +.diff-context { + background-color: var(--code-bg-color); + border-left: 3px solid transparent; +} + +/* Character-level highlighting */ +.diff-char-removed { + background-color: #ffcecb; + border-radius: 2px; +} + +.diff-char-added { + background-color: #abf2bc; + border-radius: 2px; +} + +/* Remove default mark styling */ +mark.diff-char-removed, +mark.diff-char-added { + color: inherit; +} diff --git a/claude_code_log/templates/components/filter_styles.css b/claude_code_log/templates/components/filter_styles.css index d39e1439..e6bfaa36 100644 --- a/claude_code_log/templates/components/filter_styles.css +++ b/claude_code_log/templates/components/filter_styles.css @@ -107,6 +107,32 @@ background-color: #ffffff66; } +/* Color-coded filter buttons */ +.filter-toggle[data-type="user"] { + border-color: #ff9800; + border-width: 2px; +} + +.filter-toggle[data-type="system"] { + border-color: #d98100; + border-width: 2px; +} + +.filter-toggle[data-type="tool"] { + border-color: #4caf50; + border-width: 2px; +} + +.filter-toggle[data-type="assistant"] { + border-color: #9c27b0; + border-width: 2px; +} + +.filter-toggle[data-type="thinking"] { + border-color: #9c27b066; + border-width: 2px; +} + .filter-actions { display: flex; gap: 6px; diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index 4da9c3bb..77442f46 100644 --- a/claude_code_log/templates/components/global_styles.css +++ b/claude_code_log/templates/components/global_styles.css @@ -1,6 +1,45 @@ /* Global styles shared across all templates */ + +/* CSS Variables for shared colors and consistent theming */ +:root { + /* Base colors */ + --code-bg-color: #f5f1e8; + --tool-param-sep-color: #4b494822; + + /* Dimmed/transparent variants (66 = ~40% opacity) */ + --white-dimmed: #ffffff66; + --highlight-dimmed: #e3f2fd66; + --assistant-dimmed: #9c27b066; + --info-dimmed: #2196f366; + --warning-dimmed: #d9810066; + --success-dimmed: #4caf5066; + --error-dimmed: #f4433666; + --neutral-dimmed: #f8f9fa66; + --tool-input-dimmed: #fff3cd66; + --thinking-dimmed: #f0f0f066; + --tool-result-dimmed: #e8f5e866; + --session-bg-dimmed: #e8f4fd66; + --ide-notification-dimmed: #d2d6d966; + + /* Fully transparent variants (88 = ~53% opacity) */ + --highlight-semi: #e3f2fd88; + --error-semi: #ffebee88; + --neutral-semi: #f8f9fa88; + + /* Slightly transparent variants (55 = ~33% opacity) */ + --highlight-light: #e3f2fd55; + + /* Solid colors for text and accents */ + --text-muted: #666; + --text-secondary: #495057; + + /* Font families */ + --font-monospace: 'Fira Code', 'Monaco', 'Consolas', 'SF Mono', 'Inconsolata', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', monospace; + --font-ui: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + body { - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', 'Consolas', monospace; + font-family: var(--font-monospace); line-height: 1.5; max-width: 1200px; margin: 0 auto; @@ -17,14 +56,6 @@ h1 { } /* Common typography */ -code { - background-color: #f5f5f5; - padding: 2px 4px; - border-radius: 3px; - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', 'Consolas', monospace; - line-height: 1.5; -} - pre { background-color: #12121212; padding: 10px; @@ -32,7 +63,7 @@ pre { white-space: pre-wrap; word-wrap: break-word; word-break: break-word; - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', 'Ubuntu Mono', 'Cascadia Code', 'Menlo', 'Consolas', monospace; + font-family: var(--font-monospace); line-height: 1.5; } diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index e6725e9e..8bd2502c 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -1,16 +1,43 @@ /* Message and content styles */ .message { margin-bottom: 1em; + margin-left: 1em; padding: 1em; border-radius: 8px; - border-left: #ffffff66 1px solid; - background-color: #e3f2fd55; + border-left: var(--white-dimmed) 2px solid; + background-color: var(--highlight-light); box-shadow: -7px -7px 10px #eeeeee44, 7px 7px 10px #00000011; - border-top: #ffffff66 1px solid; + border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; } +/* Paired message styling */ +.message.paired-message { + margin-bottom: 0; +} + +.message.paired-message.pair_first { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: none; +} + +.message.paired-message.pair_last { + margin-top: 0; + margin-bottom: 1em; + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: 1px solid #00000011; +} + +.message.paired-message.pair_middle { + margin-top: 0; + border-radius: 0; + border-top: 1px solid #00000011; + border-bottom: none; +} + .session-divider { margin: 70px 0; border-top: 2px solid #fff; @@ -18,36 +45,47 @@ /* Message type styling */ .user { - border-left-color: #2196f3; + border-left-color: #ff9800; + margin-left: 0; } .assistant { border-left-color: #9c27b0; } +/* Dimmed assistant when paired with thinking */ +.assistant.paired-message { + border-left-color: var(--assistant-dimmed); +} + .system { - border-left-color: #ff9800; + border-left-color: #d98100; + margin-left: 0; } .system-warning { - border-left-color: #ff9800; - background-color: #fff3e088; + border-left-color: #2196f3; + background-color: var(--highlight-semi); + margin-left: 2em; /* Extra indent - assistant-initiated */ } .system-error { border-left-color: #f44336; - background-color: #ffebee88; + background-color: var(--error-semi); + margin-left: 0; } .system-info { - border-left-color: #2196f3; - background-color: #e3f2fd88; + border-left-color: var(--info-dimmed); + background-color: var(--highlight-dimmed); + margin-left: 2em; /* Extra indent - assistant-initiated */ + font-size: 80%; } /* Command output styling */ .command-output { background-color: #1e1e1e11; - border-left-color: #00bcd4; + border-left-color: var(--warning-dimmed); } .command-output-content { @@ -56,7 +94,7 @@ border-radius: 4px; border: 1px solid #00000011; margin-top: 8px; - font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-family: var(--font-monospace); font-size: 0.9em; line-height: 1.4; white-space: pre-wrap; @@ -79,7 +117,7 @@ } .bash-command { - font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-family: var(--font-monospace); font-size: 0.95em; color: #2c3e50; background-color: #f8f9fa; @@ -89,7 +127,7 @@ /* Bash output styling */ .bash-output { - background-color: #f8f9fa66; + background-color: var(--neutral-dimmed); border-left-color: #607d8b; } @@ -99,7 +137,7 @@ border-radius: 4px; border: 1px solid #00000011; margin: 8px 0; - font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-family: var(--font-monospace); font-size: 0.9em; line-height: 1.4; white-space: pre-wrap; @@ -114,7 +152,7 @@ border-radius: 4px; border: 1px solid #ffcdd2; margin: 8px 0; - font-family: 'Fira Code', 'Monaco', 'Consolas', monospace; + font-family: var(--font-monospace); font-size: 0.9em; line-height: 1.4; white-space: pre-wrap; @@ -128,24 +166,73 @@ font-style: italic; } +/* Bash tool content styling (Tool Use message) */ +.bash-tool-content { + margin: 8px 0; +} + +.bash-tool-description { + color: var(--text-muted); + font-size: 0.95em; + margin-bottom: 8px; + line-height: 1.4; +} + +.bash-tool-command { + background-color: #f8f9fa; + padding: 8px 12px; + border-radius: 4px; + border: 1px solid var(--tool-param-sep-color); + font-family: var(--font-monospace); + font-size: 0.9em; + color: #2c3e50; + margin: 0; + overflow-x: auto; +} + .tool_use { - border-left-color: #e91e63; + border-left-color: #4caf50; + margin-left: 2em; /* Extra indent - assistant-initiated */ } .tool_result { - border-left-color: #4caf50; + border-left-color: var(--success-dimmed); + margin-left: 2em; /* Extra indent - assistant-initiated */ +} + +.tool_result.error { + border-left-color: var(--error-dimmed); + background-color: var(--error-semi); +} + +.message.tool_result pre { + font-size: 80%; } /* Sidechain message styling */ .sidechain { opacity: 0.85; - background-color: #f8f9fa88; + background-color: var(--neutral-semi); border-left-width: 2px; border-left-style: dashed; } +/* Sidechain indentation hierarchy */ +.sidechain.user { + margin-left: 3em; /* Sub-assistant Prompt - nested below Task tool use (2em) */ +} + +.sidechain.assistant { + margin-left: 4em; /* Sub-assistant - nested below prompt (3em) */ +} + +.sidechain.tool_use, +.sidechain.tool_result { + margin-left: 5em; /* Sub-assistant tools - nested below assistant (4em) */ +} + .sidechain .sidechain-indicator { - color: #666; + color: var(--text-muted); font-size: 0.9em; margin-bottom: 5px; padding: 2px 6px; @@ -155,22 +242,28 @@ } .thinking { - border-left-color: #9e9e9e; + border-left-color: var(--assistant-dimmed); +} + +/* Full purple when thinking is paired (as pair_first) */ +.thinking.paired-message.pair_first { + border-left-color: #9c27b0; } .image { - border-left-color: #ff5722; + border-left-color: #d48a5e; + margin-left: 0; /* Align with user messages */ } /* Session header styling */ .session-header { - background-color: #e8f4fd66; + background-color: var(--session-bg-dimmed); border-radius: 8px; padding: 16px; margin: 30px 0 20px 0; box-shadow: -7px -7px 10px #eeeeee44, 7px 7px 10px #00000011; - border-left: #ffffff66 1px solid; - border-top: #ffffff66 1px solid; + border-left: var(--white-dimmed) 1px solid; + border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; } @@ -180,6 +273,45 @@ font-size: 1.2em; } +/* IDE notification styling */ +.ide-notification { + background-color: var(--ide-notification-dimmed); + border-left: #9c27b0 2px solid; + padding: 8px 12px; + margin: 8px 0; + border-radius: 4px; + font-size: 0.9em; + font-style: italic; +} + +/* IDE selection styling */ +.ide-selection-collapsible { + margin-top: 4px; +} + +.ide-selection-collapsible summary { + cursor: pointer; + color: var(--text-muted); + user-select: none; +} + +.ide-selection-collapsible summary:hover { + color: #333; +} + +.ide-selection-content { + margin-top: 8px; + padding: 8px; + background-color: #f8f9fa; + border-radius: 3px; + border: 1px solid #dee2e6; + font-family: var(--font-monospace); + font-size: 0.85em; + line-height: 1.4; + white-space: pre-wrap; + overflow-x: auto; +} + /* Content styling */ .content { word-wrap: break-word; @@ -195,65 +327,136 @@ margin-left: 1em; } +/* Assistant and Thinking content styling */ +.assistant .content, +.thinking-text, +.user.compacted .content { + font-family: var(--font-ui); +} + +/* Code block styling */ +pre > code { + display: block; +} + +code { + background-color: var(--code-bg-color); +} + /* Tool content styling */ .tool-content { - background-color: #f8f9fa66; + background-color: var(--neutral-dimmed); border-radius: 4px; padding: 8px; margin: 8px 0; overflow-x: auto; box-shadow: -4px -4px 10px #eeeeee33, 4px 4px 10px #00000007; - border-left: #ffffff66 1px solid; - border-top: #ffffff66 1px solid; + border-left: var(--white-dimmed) 1px solid; + border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; } +/* Tool parameter table styling */ +.tool-params-table { + width: 100%; + border-collapse: collapse; + font-size: 80%; +} + +.tool-params-table tr { + border-bottom: 1px solid var(--tool-param-sep-color); +} + +.tool-param-key { + padding: 4px; + font-weight: 600; + color: var(--text-secondary); + vertical-align: top; + width: 8em; +} + +.tool-param-value { + padding: 4px; + color: #212529; + vertical-align: top; +} + +.tool-param-structured { + margin: 0; + background-color: #f8f9fa; + padding: 4px; + border-radius: 3px; +} + +.tool-param-collapsible { + margin: 0; +} + +.tool-param-collapsible summary { + cursor: pointer; + color: var(--text-muted); +} + +.tool-param-collapsible summary:hover { + color: #333; +} + +.tool-param-full { + margin-top: 4px; + word-break: break-all; +} + +.tool-params-empty { + color: #999; + font-style: italic; +} + .tool-result { - background-color: #e8f5e866; + background-color: var(--tool-result-dimmed); border-left: #4caf5088 1px solid; } .tool-use { - background-color: #e3f2fd66; + background-color: var(--highlight-dimmed); border-left: #2196f388 1px solid; } .thinking-content { - background-color: #f0f0f066; + background-color: var(--thinking-dimmed); border-left: #66666688 1px solid; } .thinking-text { - font-style: italic; - white-space: pre-wrap; + font-family: var(--font-ui); + font-size: 90%; word-wrap: break-word; color: #555; } .tool-input { - background-color: #fff3cd66; + background-color: var(--tool-input-dimmed); border-radius: 4px; padding: 6px; margin: 4px 0; font-size: 0.9em; box-shadow: -7px -7px 10px #eeeeee44, 7px 7px 10px #00000011; - border-left: #ffffff66 1px solid; - border-top: #ffffff66 1px solid; + border-left: var(--white-dimmed) 1px solid; + border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; } /* Session summary styling */ .session-summary { - background-color: #ffffff66; + background-color: var(--white-dimmed); border-left: #4caf5088 4px solid; padding: 12px; margin: 8px 0; border-radius: 0 4px 4px 0; font-style: italic; box-shadow: -7px -7px 10px #eeeeee44, 7px 7px 10px #00000011; - border-top: #ffffff66 1px solid; + border-top: var(--white-dimmed) 1px solid; border-bottom: #00000017 1px solid; border-right: #00000017 1px solid; } @@ -261,7 +464,7 @@ /* Collapsible details styling */ details summary { cursor: pointer; - color: #666; + color: var(--text-muted); } .collapsible-details { diff --git a/claude_code_log/templates/components/todo_styles.css b/claude_code_log/templates/components/todo_styles.css index 9f27d1bd..c1e6f71e 100644 --- a/claude_code_log/templates/components/todo_styles.css +++ b/claude_code_log/templates/components/todo_styles.css @@ -63,6 +63,8 @@ flex: 1; color: #333; font-weight: 500; + font-size: 90%; + font-family: var(--font-ui); } .todo-id { diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index db8e173e..2bd92d33 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -15,6 +15,7 @@ {% include 'components/todo_styles.css' %} {% include 'components/timeline_styles.css' %} {% include 'components/search_styles.css' %} +{% include 'components/edit_diff_styles.css' %} @@ -38,16 +39,14 @@

🔍 Search & Filter

+ - - - - -
@@ -81,12 +80,12 @@

🔍 Search & Filter

{% else %} -
+
- {% if message.css_class == 'user' %}🤷 {% elif message.css_class == 'assistant' %}🤖 {% elif + {% 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 }} + message.css_class == 'image' %}đŸ–ŧī¸ {% endif %}{{ message.display_type }}{% endif %}
{{ message.formatted_timestamp }} {% if message.token_usage %} @@ -223,13 +222,13 @@

🔍 Search & Filter

// Count messages by type and update button labels function updateMessageCounts() { - const messageTypes = ['user', 'assistant', 'sidechain', 'system', 'tool_use', 'tool_result', 'thinking', 'image']; + const messageTypes = ['user', 'assistant', 'sidechain', 'system', 'thinking', 'image']; messageTypes.forEach(type => { const messages = document.querySelectorAll(`.message.${type}:not(.session-header)`); const count = messages.length; const toggle = document.querySelector(`[data-type="${type}"]`); - const countSpan = toggle.querySelector('.count'); + const countSpan = toggle ? toggle.querySelector('.count') : null; if (countSpan) { countSpan.textContent = `(${count})`; @@ -242,6 +241,21 @@

🔍 Search & Filter

} } }); + + // Handle combined "tool" filter (tool_use + tool_result) + const toolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + const toolCount = toolMessages.length; + const toolToggle = document.querySelector(`[data-type="tool"]`); + const toolCountSpan = toolToggle ? toolToggle.querySelector('.count') : null; + + if (toolCountSpan) { + toolCountSpan.textContent = `(${toolCount})`; + if (toolCount === 0) { + toolToggle.style.display = 'none'; + } else { + toolToggle.style.display = 'flex'; + } + } } // Filter functionality @@ -250,6 +264,16 @@

🔍 Search & Filter

.filter(toggle => toggle.classList.contains('active')) .map(toggle => toggle.dataset.type); + // Expand "tool" to include both tool_use and tool_result + const expandedTypes = []; + activeTypes.forEach(type => { + if (type === 'tool') { + expandedTypes.push('tool_use', 'tool_result'); + } else { + expandedTypes.push(type); + } + }); + // Show/hide messages based on active toggle buttons const allMessages = document.querySelectorAll('.message:not(.session-header)'); allMessages.forEach(message => { @@ -258,14 +282,14 @@

🔍 Search & Filter

// Special handling for sidechain messages if (message.classList.contains('sidechain')) { // For sidechain messages, show if both sidechain filter is active AND their message type filter is active - const sidechainActive = activeTypes.includes('sidechain'); - const messageTypeActive = activeTypes.some(type => + const sidechainActive = expandedTypes.includes('sidechain'); + const messageTypeActive = expandedTypes.some(type => type !== 'sidechain' && message.classList.contains(type) ); shouldShow = sidechainActive && messageTypeActive; } else { // For non-sidechain messages, show if any of their types are active - shouldShow = activeTypes.some(type => message.classList.contains(type)); + shouldShow = expandedTypes.some(type => message.classList.contains(type)); } if (shouldShow) { @@ -288,7 +312,7 @@

🔍 Search & Filter

} function updateVisibleCounts() { - const messageTypes = ['user', 'assistant', 'sidechain', 'system', 'tool_use', 'tool_result', 'thinking', 'image']; + const messageTypes = ['user', 'assistant', 'sidechain', 'system', 'thinking', 'image']; messageTypes.forEach(type => { const visibleMessages = document.querySelectorAll(`.message.${type}:not(.session-header):not(.filtered-hidden)`); @@ -297,7 +321,7 @@

🔍 Search & Filter

const totalCount = totalMessages.length; const toggle = document.querySelector(`[data-type="${type}"]`); - const countSpan = toggle.querySelector('.count'); + const countSpan = toggle ? toggle.querySelector('.count') : null; if (countSpan && totalCount > 0) { // Show "visible/total" format when filtering is active @@ -314,6 +338,29 @@

🔍 Search & Filter

} } }); + + // Handle combined "tool" filter separately + const visibleToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header):not(.filtered-hidden), .message.tool_result:not(.session-header):not(.filtered-hidden)`); + const totalToolMessages = document.querySelectorAll(`.message.tool_use:not(.session-header), .message.tool_result:not(.session-header)`); + const visibleToolCount = visibleToolMessages.length; + const totalToolCount = totalToolMessages.length; + + const toolToggle = document.querySelector(`[data-type="tool"]`); + const toolCountSpan = toolToggle ? toolToggle.querySelector('.count') : null; + + if (toolCountSpan && totalToolCount > 0) { + const activeTypes = Array.from(filterToggles) + .filter(toggle => toggle.classList.contains('active')) + .map(toggle => toggle.dataset.type); + + const isFiltering = activeTypes.length < filterToggles.length; + + if (isFiltering && visibleToolCount !== totalToolCount) { + toolCountSpan.textContent = `(${visibleToolCount}/${totalToolCount})`; + } else { + toolCountSpan.textContent = `(${totalToolCount})`; + } + } } function toggleFilter(button) { diff --git a/test/test_ide_tags.py b/test/test_ide_tags.py new file mode 100644 index 00000000..932a53e2 --- /dev/null +++ b/test/test_ide_tags.py @@ -0,0 +1,349 @@ +"""Tests for IDE tag preprocessing in user messages.""" + +from claude_code_log.renderer import ( + extract_ide_notifications, + render_user_message_content, + render_message_content, +) +from claude_code_log.models import TextContent, ImageContent, ImageSource + + +def test_extract_ide_opened_file_tag(): + """Test that tags are extracted correctly.""" + text = ( + "The user opened the file " + "e:\\Workspace\\test.py in the IDE. This may or may not be related to the current task." + "\n" + "Here is my actual question." + ) + + notifications, remaining = extract_ide_notifications(text) + + # Should have one notification + assert len(notifications) == 1 + # Should contain the IDE notification div + assert "
" in notifications[0] + # Should have bot emoji prefix + assert "🤖" in notifications[0] + # Should escape the content properly + assert ( + "e:\\Workspace\\test.py" in notifications[0] + or "e:\\\\Workspace\\\\test.py" in notifications[0] + ) + # Remaining text should not have the tag + assert remaining == "Here is my actual question." + + +def test_extract_multiple_ide_tags(): + """Test handling multiple IDE tags in one message.""" + text = ( + "First file opened.\n" + "Some text in between.\n" + "Second file opened." + ) + + notifications, remaining = extract_ide_notifications(text) + + # Should have two IDE notifications + assert len(notifications) == 2 + # Each should have bot emoji + assert all("🤖" in n for n in notifications) + # Remaining text should have text in between but no tags + assert "Some text in between." in remaining + assert "" not in remaining + + +def test_extract_no_ide_tags(): + """Test that messages without IDE tags are unchanged.""" + text = "This is a regular user message without any IDE tags." + + notifications, remaining = extract_ide_notifications(text) + + # Should have no notifications + assert len(notifications) == 0 + # Remaining text should be unchanged + assert remaining == text + + +def test_extract_multiline_ide_tag(): + """Test IDE tags with multiline content.""" + text = ( + "The user opened the file\n" + "e:\\Workspace\\test.py in the IDE.\n" + "This may or may not be related.\n" + "User question follows." + ) + + notifications, remaining = extract_ide_notifications(text) + + # Should have one notification with multiline content + assert len(notifications) == 1 + assert "🤖" in notifications[0] + assert ( + "e:\\Workspace\\test.py" in notifications[0] + or "e:\\\\Workspace\\\\test.py" in notifications[0] + ) + # Remaining should have the user question + assert remaining == "User question follows." + + +def test_extract_special_chars_in_ide_tag(): + """Test that special HTML characters are escaped in IDE tag content.""" + text = ( + 'File with & "characters" in path.' + ) + + notifications, remaining = extract_ide_notifications(text) + + # Should have one notification + assert len(notifications) == 1 + # Should escape HTML special characters + assert "<special>" in notifications[0] + assert "&" in notifications[0] + assert ( + ""characters"" in notifications[0] + or "'characters'" in notifications[0] + ) + # Remaining should be empty + assert remaining == "" + + +def test_render_user_message_with_multi_item_content(): + """Test rendering user message with multiple content items (text + image).""" + # Simulate a user message with text containing IDE tag plus an image + text_with_tag = ( + "User opened example.py\n" + "Please review this code and this screenshot:" + ) + image_item = ImageContent( + type="image", + source=ImageSource( + type="base64", + media_type="image/png", + data="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + ), + ) + + content_list = [ + TextContent(type="text", text=text_with_tag), + image_item, + ] + + content_html, is_compacted = render_user_message_content(content_list) + + # Should extract IDE notification + assert "🤖" in content_html + assert "ide-notification" in content_html + assert "User opened example.py" in content_html + + # Should render remaining text + assert "Please review this code" in content_html + + # Should render image + assert " for user messages + assert html.startswith("
")
+    assert html.endswith("
") + assert "Simple user message" in html + + +def test_render_message_content_single_text_item_assistant(): + """Test that single TextContent item takes fast path for assistant messages.""" + content = [TextContent(type="text", text="**Bold** response")] + + html = render_message_content(content, "assistant") + + # Should be rendered as markdown (no
)
+    assert "
" not in html
+    # Markdown should be processed
+    assert "Bold" in html or "Bold" in html
+
+
+def test_extract_ide_diagnostics():
+    """Test extraction of IDE diagnostics from post-tool-use-hook."""
+    text = (
+        "["
+        '{"filePath": "/e:/Workspace/test.py", "line": 12, "column": 6, '
+        '"message": "Package not installed", "code": "[object Object]", "severity": "Hint"},'
+        '{"filePath": "/e:/Workspace/other.py", "line": 5, "column": 1, '
+        '"message": "Unused import", "severity": "Warning"}'
+        "]\n"
+        "Here is my question."
+    )
+
+    notifications, remaining = extract_ide_notifications(text)
+
+    # Should have two diagnostic notifications (one per diagnostic object)
+    assert len(notifications) == 2
+
+    # Each should have the warning emoji and "IDE Diagnostic" label
+    assert all("âš ī¸" in n for n in notifications)
+    assert all("IDE Diagnostic" in n for n in notifications)
+
+    # Should render as tables
+    assert all("" in n for n in notifications)
+
+    # Should contain diagnostic fields
+    assert "filePath" in notifications[0]
+    assert "/e:/Workspace/test.py" in notifications[0]
+    assert "Package not installed" in notifications[0]
+
+    assert "filePath" in notifications[1]
+    assert "/e:/Workspace/other.py" in notifications[1]
+    assert "Unused import" in notifications[1]
+
+    # Remaining text should not have the hook tags
+    assert remaining == "Here is my question."
+    assert "" not in remaining
+
+
+def test_extract_mixed_ide_tags():
+    """Test handling both ide_opened_file and ide_diagnostics together."""
+    text = (
+        "User opened config.json\n"
+        "["
+        '{"line": 10, "message": "Syntax error"}'
+        "]\n"
+        "Please review."
+    )
+
+    notifications, remaining = extract_ide_notifications(text)
+
+    # Should have 2 notifications total: 1 file open + 1 diagnostic
+    assert len(notifications) == 2
+
+    # First should be file open notification
+    assert "🤖" in notifications[0]
+    assert "User opened config.json" in notifications[0]
+
+    # Second should be diagnostic
+    assert "âš ī¸" in notifications[1]
+    assert "IDE Diagnostic" in notifications[1]
+    assert "Syntax error" in notifications[1]
+
+    # Remaining text should be clean
+    assert remaining == "Please review."
+
+
+def test_extract_ide_selection_short():
+    """Test extraction of short IDE selection."""
+    text = (
+        "The user selected the lines 7 to 7 from file.py:\n"
+        "nx_utils\n\n"
+        "This may or may not be related to the current task.\n"
+        "Can you explain this?"
+    )
+
+    notifications, remaining = extract_ide_notifications(text)
+
+    # Should have one notification
+    assert len(notifications) == 1
+
+    # Should have pencil emoji
+    assert "📝" in notifications[0]
+
+    # Should contain the selection text
+    assert "nx_utils" in notifications[0]
+    assert "lines 7 to 7" in notifications[0]
+
+    # Short selections should not be in a collapsible details element
+    assert "" not in remaining
+
+
+def test_extract_ide_selection_long():
+    """Test extraction of long IDE selection with collapsible rendering."""
+    long_selection = "The user selected lines 1 to 50:\n" + ("line content\n" * 30)
+    text = f"{long_selection}\nWhat does this do?"
+
+    notifications, remaining = extract_ide_notifications(text)
+
+    # Should have one notification
+    assert len(notifications) == 1
+
+    # Should have pencil emoji
+    assert "📝" in notifications[0]
+
+    # Long selections should be in a collapsible details element
+    assert "
" in notifications[0] + assert "" in notifications[0] + assert "
" in notifications[0]
+
+    # Should show preview in summary (truncated)
+    assert "..." in notifications[0]  # Preview indicator
+
+    # Should contain the full content in the pre block
+    assert "line content" in notifications[0]
+
+    # Remaining text should not have the tag
+    assert remaining == "What does this do?"
+    assert "" not in remaining
+
+
+def test_extract_ide_selection_with_special_chars():
+    """Test that special HTML characters are escaped in IDE selection."""
+    text = 'Code with  & "quotes"'
+
+    notifications, remaining = extract_ide_notifications(text)
+
+    # Should have one notification
+    assert len(notifications) == 1
+
+    # Should escape HTML special characters
+    assert "<brackets>" in notifications[0]
+    assert "&" in notifications[0]
+    assert (
+        ""quotes"" in notifications[0]
+        or "'quotes'" in notifications[0]
+    )
+
+    # Remaining should be empty
+    assert remaining == ""
+
+
+def test_extract_all_ide_tag_types():
+    """Test handling all IDE tag types together."""
+    text = (
+        "User opened main.py\n"
+        "selected_variable\n"
+        "["
+        '{"line": 5, "message": "Unused variable"}'
+        "]\n"
+        "Please help."
+    )
+
+    notifications, remaining = extract_ide_notifications(text)
+
+    # Should have 3 notifications total: 1 file + 1 selection + 1 diagnostic
+    assert len(notifications) == 3
+
+    # First should be file open
+    assert "🤖" in notifications[0]
+    assert "User opened main.py" in notifications[0]
+
+    # Second should be selection
+    assert "📝" in notifications[1]
+    assert "selected_variable" in notifications[1]
+
+    # Third should be diagnostic
+    assert "âš ī¸" in notifications[2]
+    assert "IDE Diagnostic" in notifications[2]
+    assert "Unused variable" in notifications[2]
+
+    # Remaining text should be clean
+    assert remaining == "Please help."
diff --git a/test/test_template_rendering.py b/test/test_template_rendering.py
index 7b767c03..3e0803a0 100644
--- a/test/test_template_rendering.py
+++ b/test/test_template_rendering.py
@@ -52,7 +52,7 @@ def test_representative_messages_render(self):
         )
         assert "Python decorators" in html_content
         assert "Tool Use:" in html_content
-        assert "Tool Result:" in html_content
+        assert "Tool Result" in html_content  # Changed: no colon for non-error results
 
         # Check that markdown elements are rendered server-side
         assert (
@@ -83,7 +83,7 @@ def test_edge_cases_render(self):
 
         # Check tool error handling
         assert "Tool Result" in html_content
-        assert "Error):" in html_content
+        assert "🚨 Error" in html_content  # Changed: error indicator format
         assert "Tool execution failed" in html_content
 
         # Check system message filtering (caveat should be filtered out)
@@ -169,7 +169,7 @@ def test_tool_content_rendering(self):
         assert "tool-use" in html_content
 
         # Check tool result formatting
-        assert "Tool Result:" in html_content
+        assert "Tool Result" in html_content  # Changed: no colon for non-error results
         assert "File created successfully" in html_content
         assert "tool-result" in html_content
 
@@ -254,9 +254,9 @@ def test_css_classes_applied(self):
         # Summary messages are now integrated into session headers
         assert "session-summary" in html_content or "Summary:" in html_content
 
-        # Check tool message classes (tools are now top-level messages)
-        assert "class='message tool_use'" in html_content
-        assert "class='message tool_result'" in html_content
+        # Check tool message classes (tools are now top-level messages, may include paired-message class)
+        assert "tool_use" in html_content and "class='message" in html_content
+        assert "tool_result" in html_content and "class='message" in html_content
 
     def test_server_side_markdown_rendering(self):
         """Test that markdown is rendered server-side, not client-side."""
diff --git a/test/test_timeline_browser.py b/test/test_timeline_browser.py
index c1dec41a..4063b173 100644
--- a/test/test_timeline_browser.py
+++ b/test/test_timeline_browser.py
@@ -723,8 +723,7 @@ def test_timeline_filter_individual_message_types(self, page: Page):
             ("user", "User"),
             ("assistant", "Assistant"),
             ("sidechain", "Sub-assistant"),
-            ("tool_use", "Tool use"),
-            ("tool_result", "Tool result"),
+            ("tool", "Tool"),  # Unified filter for both tool_use and tool_result
             ("thinking", "Thinking"),
             ("system", "System"),
         ]
@@ -917,7 +916,11 @@ def test_timeline_filter_message_type_coverage(self, page: Page):
 
         # Check that filter toggles exist for all message types found
         for message_type in message_type_classes:
-            filter_selector = f'.filter-toggle[data-type="{message_type}"]'
+            # Map tool_use and tool_result to unified "tool" filter
+            filter_type = (
+                "tool" if message_type in ["tool_use", "tool_result"] else message_type
+            )
+            filter_selector = f'.filter-toggle[data-type="{filter_type}"]'
             filter_toggle = page.locator(filter_selector)
 
             if message_type in [
@@ -931,7 +934,7 @@ def test_timeline_filter_message_type_coverage(self, page: Page):
             ]:
                 # These message types should have filter toggles
                 assert filter_toggle.count() > 0, (
-                    f"Filter toggle should exist for message type: {message_type}"
+                    f"Filter toggle should exist for message type: {message_type} (filter: {filter_type})"
                 )
 
             # Test that timeline can handle filtering for this message type
diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py
index eadb085b..ac8ec4ac 100644
--- a/test/test_todowrite_rendering.py
+++ b/test/test_todowrite_rendering.py
@@ -256,9 +256,10 @@ def test_todowrite_vs_regular_tool_use(self):
         regular_html = format_tool_use_content(regular_tool)
         todowrite_html = format_tool_use_content(todowrite_tool)
 
-        # Regular tool should use standard formatting
-        assert 'class="collapsible-details"' in regular_html
-        assert "" in regular_html
+        # 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
         # Tool name/ID no longer in content, moved to message header
 
         # TodoWrite should use special formatting
diff --git a/test/test_toggle_functionality.py b/test/test_toggle_functionality.py
index 199fb600..35b35ed1 100644
--- a/test/test_toggle_functionality.py
+++ b/test/test_toggle_functionality.py
@@ -98,7 +98,7 @@ def test_toggle_button_with_no_collapsible_content(self):
 
     def test_collapsible_details_structure(self):
         """Test the structure of collapsible details elements."""
-        # Create content long enough to trigger collapsible details
+        # Create content long enough to trigger collapsible in tool params
         long_input = {
             "data": "x" * 300
         }  # Definitely over 200 chars when JSON serialized
@@ -113,11 +113,12 @@ def test_collapsible_details_structure(self):
 
         html = generate_html([message], "Test Structure")
 
-        # Check for collapsible details structure
-        assert 'class="collapsible-details"' in html, "Should have collapsible details"
+        # Check for tool parameter table with collapsible details
+        assert "class='tool-params-table'" in html, "Should have tool params table"
         assert "" in html, "Should have summary element"
-        assert 'class="preview-content"' in html, "Should have preview content"
-        assert 'class="details-content"' in html, "Should have details content"
+        assert "class='tool-param-collapsible'" in html, (
+            "Should have collapsible tool param"
+        )
 
     def test_collapsible_details_css_selectors(self):
         """Test that the CSS selectors used in JavaScript are present."""
@@ -179,14 +180,15 @@ def test_multiple_collapsible_elements(self):
 
         html = generate_html([message], "Test Multiple")
 
-        # Should have multiple collapsible details (only count actual HTML elements, not in JS)
+        # Should have multiple collapsible tool params (only count actual HTML elements, not in JS)
         import re
 
         # Remove script tags and their content to avoid counting strings in JavaScript
         html_without_scripts = re.sub(r"", "", html, flags=re.DOTALL)
-        collapsible_count = html_without_scripts.count('class="collapsible-details"')
+        collapsible_count = html_without_scripts.count("class='tool-param-collapsible'")
+        # Each tool has 2 params (content and index), so 3 tools = 6 params, but only content is long enough to be collapsible
         assert collapsible_count == 3, (
-            f"Should have 3 collapsible details, got {collapsible_count}"
+            f"Should have 3 collapsible tool params, got {collapsible_count}"
         )
 
         # Toggle logic should handle multiple elements