diff --git a/PLAN_PHASE12.md b/PLAN_PHASE12.md new file mode 100644 index 00000000..1dd2836d --- /dev/null +++ b/PLAN_PHASE12.md @@ -0,0 +1,345 @@ +# Phase 12: Format-Neutral Decomposition Plan + +## Overview + +This plan separates format-neutral logic from HTML-specific generation in renderer.py. The goal is to: +1. Create a `TemplateMessage` that stores logical attributes instead of CSS classes +2. Move HTML-specific rendering to a new `html_renderer.py` module +3. Keep format-neutral processing in `renderer.py` (to be renamed later) + +## Key Design Decisions + +### 1. Replace `css_class` with Typed Attributes + +Instead of encoding traits as space-separated CSS classes (e.g., `"user sidechain slash-command"`), we'll use explicit fields: + +```python +# In models.py - add MessageModifiers dataclass +@dataclass +class MessageModifiers: + """Semantic modifiers for message rendering.""" + is_sidechain: bool = False + is_slash_command: bool = False + is_command_output: bool = False + is_compacted: bool = False + is_error: bool = False + is_steering: bool = False + system_level: Optional[str] = None # "info", "warning", "error", "hook" +``` + +The `TemplateMessage` will have: +- `type: MessageType` (already have the enum) +- `modifiers: MessageModifiers` (new) +- Remove `css_class` field + +### 2. HTML Renderer Module (`html_renderer.py`) + +New module containing HTML-specific functions: + +```python +# html_renderer.py + +def css_class_from_message(msg: TemplateMessage) -> str: + """Generate CSS class string from message type and modifiers.""" + parts = [msg.type.value] + if msg.modifiers.is_sidechain: + parts.append("sidechain") + if msg.modifiers.is_slash_command: + parts.append("slash-command") + if msg.modifiers.is_command_output: + parts.append("command-output") + if msg.modifiers.is_compacted: + parts.append("compacted") + if msg.modifiers.is_error: + parts.append("error") + if msg.modifiers.is_steering: + parts.append("steering") + if msg.modifiers.system_level: + parts.append(f"system-{msg.modifiers.system_level}") + return " ".join(parts) + +def get_message_emoji(msg: TemplateMessage) -> str: + """Return emoji for message type.""" + # Move emoji logic from template to here + +def render_content_html(msg: TemplateMessage) -> str: + """Render message content to HTML.""" + # Delegates to format_* functions +``` + +### 3. Keep Format-Neutral Processing in renderer.py + +Functions that stay in renderer.py (format-neutral): +- `_process_messages_loop()` - but sets `modifiers` instead of `css_class` +- `_identify_message_pairs()` - pairing logic +- `_build_message_hierarchy()` - but uses `type` and `modifiers` instead of `css_class` +- `_reorder_paired_messages()` - reordering logic +- Deduplication logic +- Token aggregation + +### 4. Migration Strategy + +The migration will be done in phases to minimize disruption: + +**Phase 12a: Add MessageModifiers** +- Add `MessageModifiers` dataclass to `models.py` +- Add `modifiers` field to `TemplateMessage` +- Keep `css_class` field for backward compatibility + +**Phase 12b: Populate Modifiers** +- Update all TemplateMessage creation sites to set `modifiers` +- Replace `"x" in css_class` checks with `modifiers.is_x` + +**Phase 12c: Create html_renderer.py** +- Move `escape_html()`, `render_markdown()` to html_renderer.py +- Create `css_class_from_message()` function +- Move tool formatters to html_renderer.py + +**Phase 12d: Update Templates** +- Modify template to call `css_class_from_message(message)` +- Update emoji logic to use modifiers + +**Phase 12e: Remove css_class** +- Remove `css_class` parameter from TemplateMessage +- Clean up any remaining references + +## Detailed Implementation + +### Phase 12a: Add MessageModifiers (models.py) + +```python +from dataclasses import dataclass, field +from typing import Optional + +@dataclass +class MessageModifiers: + """Semantic modifiers that affect message display. + + These are format-neutral flags that renderers can use to determine + how to display a message. HTML renderer converts these to CSS classes, + text renderer might use them for indentation or formatting. + """ + is_sidechain: bool = False + is_slash_command: bool = False + is_command_output: bool = False + is_compacted: bool = False + is_error: bool = False + is_steering: bool = False + # System message level (mutually exclusive) + system_level: Optional[str] = None # "info", "warning", "error", "hook" +``` + +Add to TemplateMessage.__init__: +```python +def __init__( + self, + message_type: str, # Will become MessageType + content_html: str, + formatted_timestamp: str, + css_class: str, # Keep for now, will remove in 12e + modifiers: Optional[MessageModifiers] = None, # New + # ... other params +): + self.type = message_type + self.modifiers = modifiers or MessageModifiers() + # ... rest +``` + +### Phase 12b: Populate Modifiers + +Update each TemplateMessage creation site. Example from `_process_system_message`: + +```python +# Before +css_class = f"{message_type}" +if is_sidechain: + css_class = f"{css_class} sidechain" + +# After +modifiers = MessageModifiers(is_sidechain=is_sidechain) +css_class = f"{message_type}" # Keep for backward compat +if is_sidechain: + css_class = f"{css_class} sidechain" +``` + +Update `_get_message_hierarchy_level()`: +```python +# Before +if "sidechain" in css_class: + ... + +# After +def _get_message_hierarchy_level(msg: TemplateMessage) -> int: + is_sidechain = msg.modifiers.is_sidechain + msg_type = msg.type + + if msg_type == MessageType.USER and not is_sidechain: + return 1 + # ... +``` + +### Phase 12c: Create html_renderer.py + +```python +"""HTML-specific rendering utilities. + +This module contains all HTML generation code: +- CSS class computation +- HTML escaping +- Markdown rendering +- Tool-specific formatters +""" + +from html import escape +from typing import Optional, List +import mistune + +from .models import MessageType, MessageModifiers, TemplateMessage + + +def escape_html(text: str) -> str: + """Escape HTML special characters.""" + return escape(text, quote=True) + + +def render_markdown(text: str) -> str: + """Convert markdown to HTML.""" + return mistune.html(text) + + +def css_class_from_message(msg: TemplateMessage) -> str: + """Generate CSS class string from message type and modifiers. + + This reconstructs the original css_class format for backward + compatibility with existing CSS and JavaScript. + """ + parts: List[str] = [msg.type.value if isinstance(msg.type, MessageType) else msg.type] + + mods = msg.modifiers + if mods.is_slash_command: + parts.append("slash-command") + if mods.is_command_output: + parts.append("command-output") + if mods.is_compacted: + parts.append("compacted") + if mods.is_error: + parts.append("error") + if mods.is_steering: + parts.append("steering") + if mods.is_sidechain: + parts.append("sidechain") + if mods.system_level: + parts.append(f"system-{mods.system_level}") + + return " ".join(parts) + + +def get_message_emoji(msg: TemplateMessage) -> str: + """Return appropriate emoji for message type.""" + msg_type = msg.type if isinstance(msg.type, MessageType) else msg.type + + if msg_type == MessageType.SESSION_HEADER: + return "📋" + elif msg_type == MessageType.USER: + return "🤷" + elif msg_type == MessageType.ASSISTANT: + return "🤖" + elif msg_type == MessageType.SYSTEM: + return "⚙️" + elif msg_type == MessageType.TOOL_USE: + return "🛠️" + elif msg_type == MessageType.TOOL_RESULT: + if msg.modifiers.is_error: + return "🚨" + return "🧰" + elif msg_type == MessageType.THINKING: + return "💭" + elif msg_type == MessageType.IMAGE: + return "🖼️" + return "" + + +# Move format_* tool functions here: +# - format_ask_user_question_tool_content +# - format_todo_write_tool_content +# - format_bash_tool_content +# etc. +``` + +### Phase 12d: Update Templates + +Update transcript.html to use the new functions. Register them as Jinja filters or pass as context: + +```python +# In renderer.py when rendering template +from .html_renderer import css_class_from_message, get_message_emoji + +template = env.get_template("transcript.html") +html = template.render( + messages=messages, + css_class_from_message=css_class_from_message, + get_message_emoji=get_message_emoji, + # ... +) +``` + +Template changes: +```jinja +{# Before #} +
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 convert_ansi_to_html(text: str) -> str:
+ """Convert ANSI escape codes to HTML spans with CSS classes.
+
+ Supports:
+ - Colors (30-37, 90-97 for foreground; 40-47, 100-107 for background)
+ - RGB colors (38;2;r;g;b for foreground; 48;2;r;g;b for background)
+ - Bold (1), Dim (2), Italic (3), Underline (4)
+ - Reset (0, 39, 49, 22, 23, 24)
+ - Strips cursor movement and screen manipulation codes
+ """
+ # First, strip cursor movement and screen manipulation codes
+ # Common patterns: [1A (cursor up), [2K (erase line), [?25l (hide cursor), etc.
+ cursor_patterns = [
+ r"\x1b\[[0-9]*[ABCD]", # Cursor movement (up, down, forward, back)
+ r"\x1b\[[0-9]*[EF]", # Cursor next/previous line
+ r"\x1b\[[0-9]*[GH]", # Cursor horizontal/home position
+ r"\x1b\[[0-9;]*[Hf]", # Cursor position
+ r"\x1b\[[0-9]*[JK]", # Erase display/line
+ r"\x1b\[[0-9]*[ST]", # Scroll up/down
+ r"\x1b\[\?[0-9]*[hl]", # Private mode set/reset (show/hide cursor, etc.)
+ r"\x1b\[[0-9]*[PXYZ@]", # Insert/delete operations
+ r"\x1b\[=[0-9]*[A-Za-z]", # Alternate character set
+ r"\x1b\][0-9];[^\x07]*\x07", # Operating System Command (OSC)
+ r"\x1b\][0-9];[^\x1b]*\x1b\\", # OSC with string terminator
+ ]
+
+ # Strip all cursor movement and screen manipulation codes
+ for pattern in cursor_patterns:
+ text = re.sub(pattern, "", text)
+
+ # Also strip any remaining unhandled escape sequences that aren't color codes
+ # This catches any we might have missed, but preserves \x1b[...m color codes
+ text = re.sub(r"\x1b\[(?![0-9;]*m)[0-9;]*[A-Za-z]", "", text)
+
+ result: List[str] = []
+ segments: List[Dict[str, Any]] = []
+
+ # First pass: split text into segments with their styles
+ last_end = 0
+ current_fg = None
+ current_bg = None
+ current_bold = False
+ current_dim = False
+ current_italic = False
+ current_underline = False
+ current_rgb_fg = None
+ current_rgb_bg = None
+
+ for match in re.finditer(r"\x1b\[([0-9;]+)m", text):
+ # Add text before this escape code
+ if match.start() > last_end:
+ segments.append(
+ {
+ "text": text[last_end : match.start()],
+ "fg": current_fg,
+ "bg": current_bg,
+ "bold": current_bold,
+ "dim": current_dim,
+ "italic": current_italic,
+ "underline": current_underline,
+ "rgb_fg": current_rgb_fg,
+ "rgb_bg": current_rgb_bg,
+ }
+ )
+
+ # Process escape codes
+ codes = match.group(1).split(";")
+ i = 0
+ while i < len(codes):
+ code = codes[i]
+
+ # Reset codes
+ if code == "0":
+ current_fg = None
+ current_bg = None
+ current_bold = False
+ current_dim = False
+ current_italic = False
+ current_underline = False
+ current_rgb_fg = None
+ current_rgb_bg = None
+ elif code == "39":
+ current_fg = None
+ current_rgb_fg = None
+ elif code == "49":
+ current_bg = None
+ current_rgb_bg = None
+ elif code == "22":
+ current_bold = False
+ current_dim = False
+ elif code == "23":
+ current_italic = False
+ elif code == "24":
+ current_underline = False
+
+ # Style codes
+ elif code == "1":
+ current_bold = True
+ elif code == "2":
+ current_dim = True
+ elif code == "3":
+ current_italic = True
+ elif code == "4":
+ current_underline = True
+
+ # Standard foreground colors
+ elif code in ["30", "31", "32", "33", "34", "35", "36", "37"]:
+ color_map = {
+ "30": "black",
+ "31": "red",
+ "32": "green",
+ "33": "yellow",
+ "34": "blue",
+ "35": "magenta",
+ "36": "cyan",
+ "37": "white",
+ }
+ current_fg = f"ansi-{color_map[code]}"
+ current_rgb_fg = None
+
+ # Standard background colors
+ elif code in ["40", "41", "42", "43", "44", "45", "46", "47"]:
+ color_map = {
+ "40": "black",
+ "41": "red",
+ "42": "green",
+ "43": "yellow",
+ "44": "blue",
+ "45": "magenta",
+ "46": "cyan",
+ "47": "white",
+ }
+ current_bg = f"ansi-bg-{color_map[code]}"
+ current_rgb_bg = None
+
+ # Bright foreground colors
+ elif code in ["90", "91", "92", "93", "94", "95", "96", "97"]:
+ color_map = {
+ "90": "bright-black",
+ "91": "bright-red",
+ "92": "bright-green",
+ "93": "bright-yellow",
+ "94": "bright-blue",
+ "95": "bright-magenta",
+ "96": "bright-cyan",
+ "97": "bright-white",
+ }
+ current_fg = f"ansi-{color_map[code]}"
+ current_rgb_fg = None
+
+ # Bright background colors
+ elif code in ["100", "101", "102", "103", "104", "105", "106", "107"]:
+ color_map = {
+ "100": "bright-black",
+ "101": "bright-red",
+ "102": "bright-green",
+ "103": "bright-yellow",
+ "104": "bright-blue",
+ "105": "bright-magenta",
+ "106": "bright-cyan",
+ "107": "bright-white",
+ }
+ current_bg = f"ansi-bg-{color_map[code]}"
+ current_rgb_bg = None
+
+ # RGB foreground color
+ elif code == "38" and i + 1 < len(codes) and codes[i + 1] == "2":
+ if i + 4 < len(codes):
+ r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
+ current_rgb_fg = f"color: rgb({r}, {g}, {b})"
+ current_fg = None
+ i += 4
+
+ # RGB background color
+ elif code == "48" and i + 1 < len(codes) and codes[i + 1] == "2":
+ if i + 4 < len(codes):
+ r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
+ current_rgb_bg = f"background-color: rgb({r}, {g}, {b})"
+ current_bg = None
+ i += 4
+
+ i += 1
+
+ last_end = match.end()
+
+ # Add remaining text
+ if last_end < len(text):
+ segments.append(
+ {
+ "text": text[last_end:],
+ "fg": current_fg,
+ "bg": current_bg,
+ "bold": current_bold,
+ "dim": current_dim,
+ "italic": current_italic,
+ "underline": current_underline,
+ "rgb_fg": current_rgb_fg,
+ "rgb_bg": current_rgb_bg,
+ }
+ )
+
+ # Second pass: build HTML
+ for segment in segments:
+ if not segment["text"]:
+ continue
+
+ classes: List[str] = []
+ styles: List[str] = []
+
+ if segment["fg"]:
+ classes.append(segment["fg"])
+ if segment["bg"]:
+ classes.append(segment["bg"])
+ if segment["bold"]:
+ classes.append("ansi-bold")
+ if segment["dim"]:
+ classes.append("ansi-dim")
+ if segment["italic"]:
+ classes.append("ansi-italic")
+ if segment["underline"]:
+ classes.append("ansi-underline")
+ if segment["rgb_fg"]:
+ styles.append(segment["rgb_fg"])
+ if segment["rgb_bg"]:
+ styles.append(segment["rgb_bg"])
+
+ escaped_text = _escape_html(segment["text"])
+
+ if classes or styles:
+ attrs: List[str] = []
+ if classes:
+ attrs.append(f'class="{" ".join(classes)}"')
+ if styles:
+ attrs.append(f'style="{"; ".join(styles)}"')
+ result.append(f"{escaped_text}")
+ else:
+ result.append(escaped_text)
+
+ return "".join(result)
diff --git a/claude_code_log/html/assistant_formatters.py b/claude_code_log/html/assistant_formatters.py
new file mode 100644
index 00000000..63f6f61f
--- /dev/null
+++ b/claude_code_log/html/assistant_formatters.py
@@ -0,0 +1,109 @@
+"""HTML formatters for assistant message content.
+
+This module formats assistant message content types to HTML.
+Part of the thematic formatter organization:
+- system_formatters.py: SystemContent, HookSummaryContent
+- user_formatters.py: SlashCommandContent, CommandOutputContent, BashInputContent
+- assistant_formatters.py: AssistantTextContent, ThinkingContentModel, ImageContent
+- tool_formatters.py: tool use/result content
+
+Content models are defined in models.py, this module only handles formatting.
+"""
+
+from ..models import (
+ AssistantTextContent,
+ ImageContent,
+ ThinkingContentModel,
+ UnknownContent,
+)
+from .utils import escape_html, render_markdown_collapsible
+
+
+# =============================================================================
+# Formatting Functions
+# =============================================================================
+
+
+def format_assistant_text_content(
+ content: AssistantTextContent,
+ line_threshold: int = 30,
+ preview_line_count: int = 10,
+) -> str:
+ """Format assistant text content as HTML.
+
+ Args:
+ content: AssistantTextContent with the text to render
+ line_threshold: Number of lines before content becomes collapsible
+ preview_line_count: Number of preview lines to show when collapsed
+
+ Returns:
+ HTML string with markdown-rendered, optionally collapsible content
+ """
+ return render_markdown_collapsible(
+ content.text,
+ "assistant-text",
+ line_threshold=line_threshold,
+ preview_line_count=preview_line_count,
+ )
+
+
+def format_thinking_content(
+ content: ThinkingContentModel,
+ line_threshold: int = 20,
+ preview_line_count: int = 5,
+) -> str:
+ """Format thinking content as HTML.
+
+ Args:
+ content: ThinkingContentModel with the thinking text
+ line_threshold: Number of lines before content becomes collapsible
+ preview_line_count: Number of preview lines to show when collapsed
+
+ Returns:
+ HTML string with markdown-rendered, optionally collapsible thinking content
+ """
+ return render_markdown_collapsible(
+ content.thinking,
+ "thinking-content",
+ line_threshold=line_threshold,
+ preview_line_count=preview_line_count,
+ )
+
+
+def format_image_content(image: ImageContent) -> str:
+ """Format image content as HTML.
+
+ Args:
+ image: ImageContent with base64 image data
+
+ Returns:
+ HTML img tag with data URL
+ """
+ data_url = f"data:{image.source.media_type};base64,{image.source.data}"
+ return f'
'
+
+
+def format_unknown_content(content: UnknownContent) -> str:
+ """Format unknown content type as HTML.
+
+ Args:
+ content: UnknownContent with the type name
+
+ Returns:
+ HTML paragraph with escaped type name
+ """
+ escaped_type = escape_html(content.type_name)
+ return f"Unknown content type: {escaped_type}
"
+
+
+# =============================================================================
+# Public Exports
+# =============================================================================
+
+__all__ = [
+ # Formatting functions
+ "format_assistant_text_content",
+ "format_thinking_content",
+ "format_image_content",
+ "format_unknown_content",
+]
diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py
new file mode 100644
index 00000000..c2efdc8f
--- /dev/null
+++ b/claude_code_log/html/renderer.py
@@ -0,0 +1,336 @@
+"""HTML renderer implementation for Claude Code transcripts."""
+
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
+
+from ..cache import get_library_version
+from ..models import (
+ AssistantTextContent,
+ BashInputContent,
+ BashOutputContent,
+ CommandOutputContent,
+ CompactedSummaryContent,
+ DedupNoticeContent,
+ HookSummaryContent,
+ ImageContent,
+ SessionHeaderContent,
+ SlashCommandContent,
+ SystemContent,
+ ThinkingContentModel,
+ ToolResultContent,
+ ToolResultContentModel,
+ ToolUseContent,
+ TranscriptEntry,
+ UnknownContent,
+ UserMemoryContent,
+ UserTextContent,
+)
+from ..renderer import (
+ Renderer,
+ TemplateMessage,
+ generate_template_messages,
+ prepare_projects_index,
+ title_for_projects_index,
+)
+from ..renderer_timings import log_timing
+from .system_formatters import (
+ format_dedup_notice_content,
+ format_hook_summary_content,
+ format_session_header_content,
+ format_system_content,
+)
+from .user_formatters import (
+ format_bash_input_content,
+ format_bash_output_content,
+ format_command_output_content,
+ format_compacted_summary_content,
+ format_slash_command_content,
+ format_user_memory_content,
+ format_user_text_model_content,
+)
+from .assistant_formatters import (
+ format_assistant_text_content,
+ format_image_content,
+ format_thinking_content,
+ format_unknown_content,
+)
+from .tool_formatters import format_tool_result_content, format_tool_use_content
+from .utils import css_class_from_message, get_message_emoji, get_template_environment
+
+if TYPE_CHECKING:
+ from ..cache import CacheManager
+
+
+def check_html_version(html_file_path: Path) -> Optional[str]:
+ """Check the version of an existing HTML file from its comment.
+
+ Returns:
+ The version string if found, None if no version comment or file doesn't exist.
+ """
+ if not html_file_path.exists():
+ return None
+
+ try:
+ with open(html_file_path, "r", encoding="utf-8") as f:
+ # Read only the first few lines to find the version comment
+ for _ in range(5): # Check first 5 lines
+ line = f.readline()
+ if not line:
+ break
+ # Look for comment like:
+ if "'
+ start = line.find("v") + 1
+ end = line.find(" -->")
+ if start > 0 and end > start:
+ return line[start:end]
+ except (IOError, UnicodeDecodeError):
+ pass
+
+ return None
+
+
+class HtmlRenderer(Renderer):
+ """HTML renderer for Claude Code transcripts."""
+
+ def _format_message_content(self, message: TemplateMessage) -> str:
+ """Format structured content to HTML for a single message.
+
+ Args:
+ message: TemplateMessage with content model to format
+
+ Returns:
+ HTML string for the message content, or empty string if no content
+ """
+ if message.content is None:
+ return ""
+
+ # Dispatch to appropriate formatter based on content type
+ if isinstance(message.content, SystemContent):
+ return format_system_content(message.content)
+ elif isinstance(message.content, HookSummaryContent):
+ return format_hook_summary_content(message.content)
+ elif isinstance(message.content, SessionHeaderContent):
+ return format_session_header_content(message.content)
+ elif isinstance(message.content, DedupNoticeContent):
+ return format_dedup_notice_content(message.content)
+ elif isinstance(message.content, SlashCommandContent):
+ return format_slash_command_content(message.content)
+ elif isinstance(message.content, CommandOutputContent):
+ return format_command_output_content(message.content)
+ elif isinstance(message.content, BashInputContent):
+ return format_bash_input_content(message.content)
+ elif isinstance(message.content, BashOutputContent):
+ return format_bash_output_content(message.content)
+ elif isinstance(message.content, ThinkingContentModel):
+ return format_thinking_content(message.content, line_threshold=10)
+ elif isinstance(message.content, AssistantTextContent):
+ return format_assistant_text_content(message.content)
+ elif isinstance(message.content, ImageContent):
+ return format_image_content(message.content)
+ elif isinstance(message.content, ToolUseContent):
+ return format_tool_use_content(message.content)
+ elif isinstance(message.content, ToolResultContentModel):
+ # Create ToolResultContent from the model for formatting
+ tool_result = ToolResultContent(
+ type="tool_result",
+ tool_use_id=message.content.tool_use_id,
+ content=message.content.content,
+ is_error=message.content.is_error,
+ )
+ return format_tool_result_content(
+ tool_result,
+ message.content.file_path,
+ message.content.tool_name,
+ )
+ # User message content types
+ elif isinstance(message.content, CompactedSummaryContent):
+ return format_compacted_summary_content(message.content)
+ elif isinstance(message.content, UserMemoryContent):
+ return format_user_memory_content(message.content)
+ elif isinstance(message.content, UserTextContent):
+ # Check if this is a slash command expanded prompt (via modifiers)
+ if message.modifiers and message.modifiers.is_slash_command:
+ # Slash command expanded prompts are markdown (LLM-generated)
+ from .utils import render_markdown_collapsible
+
+ return render_markdown_collapsible(
+ message.content.text,
+ "slash-command-content",
+ line_threshold=20,
+ preview_line_count=5,
+ )
+ else:
+ return format_user_text_model_content(message.content)
+ elif isinstance(message.content, UnknownContent):
+ return format_unknown_content(message.content)
+ # Future content types will be added here as they are migrated
+ return ""
+
+ def _flatten_preorder(
+ self, roots: List[TemplateMessage]
+ ) -> List[Tuple[TemplateMessage, str]]:
+ """Flatten message tree via pre-order traversal, formatting each message.
+
+ Traverses the tree depth-first (pre-order), formats each message's
+ content to HTML, and builds a flat list of (message, html) pairs.
+
+ Args:
+ roots: Root messages (typically session headers) with children populated
+
+ Returns:
+ Flat list of (message, html_content) tuples in pre-order
+ """
+ flat: List[Tuple[TemplateMessage, str]] = []
+
+ def visit(msg: TemplateMessage) -> None:
+ html = self._format_message_content(msg)
+ flat.append((msg, html))
+ for child in msg.children:
+ visit(child)
+
+ for root in roots:
+ visit(root)
+
+ return flat
+
+ def generate(
+ self,
+ messages: List[TranscriptEntry],
+ title: Optional[str] = None,
+ combined_transcript_link: Optional[str] = None,
+ ) -> str:
+ """Generate HTML from transcript messages."""
+ import time
+
+ t_start = time.time()
+
+ if not title:
+ title = "Claude Transcript"
+
+ # Get root messages (tree) and session navigation from format-neutral renderer
+ root_messages, session_nav = generate_template_messages(messages)
+
+ # Flatten tree via pre-order traversal, formatting content along the way
+ with log_timing("Content formatting (pre-order)", t_start):
+ template_messages = self._flatten_preorder(root_messages)
+
+ # Render template
+ with log_timing("Template environment setup", t_start):
+ env = get_template_environment()
+ template = env.get_template("transcript.html")
+
+ with log_timing(
+ lambda: f"Template rendering ({len(html_output)} chars)", t_start
+ ):
+ html_output = str(
+ template.render(
+ title=title,
+ messages=template_messages,
+ sessions=session_nav,
+ combined_transcript_link=combined_transcript_link,
+ library_version=get_library_version(),
+ css_class_from_message=css_class_from_message,
+ get_message_emoji=get_message_emoji,
+ )
+ )
+
+ return html_output
+
+ def generate_session(
+ self,
+ messages: List[TranscriptEntry],
+ session_id: str,
+ title: Optional[str] = None,
+ cache_manager: Optional["CacheManager"] = None,
+ ) -> str:
+ """Generate HTML for a single session."""
+ # Filter messages for this session (SummaryTranscriptEntry.sessionId is always None)
+ session_messages = [msg for msg in messages if msg.sessionId == session_id]
+
+ # Get combined transcript link if cache manager is available
+ combined_link = None
+ if cache_manager is not None:
+ try:
+ project_cache = cache_manager.get_cached_project_data()
+ if project_cache and project_cache.sessions:
+ combined_link = "combined_transcripts.html"
+ except Exception:
+ pass
+
+ return self.generate(
+ session_messages,
+ title or f"Session {session_id[:8]}",
+ combined_transcript_link=combined_link,
+ )
+
+ def generate_projects_index(
+ self,
+ project_summaries: List[Dict[str, Any]],
+ from_date: Optional[str] = None,
+ to_date: Optional[str] = None,
+ ) -> str:
+ """Generate an HTML projects index page."""
+ title = title_for_projects_index(project_summaries, from_date, to_date)
+ template_projects, template_summary = prepare_projects_index(project_summaries)
+
+ env = get_template_environment()
+ template = env.get_template("index.html")
+ return str(
+ template.render(
+ title=title,
+ projects=template_projects,
+ summary=template_summary,
+ library_version=get_library_version(),
+ )
+ )
+
+ def is_outdated(self, file_path: Path) -> bool:
+ """Check if an HTML file is outdated based on version.
+
+ Returns:
+ True if the file should be regenerated (missing version,
+ different version, or file doesn't exist).
+ False if the file is current.
+ """
+ html_version = check_html_version(file_path)
+ current_version = get_library_version()
+ # If no version found or different version, it's outdated
+ return html_version != current_version
+
+
+# -- Convenience Functions ----------------------------------------------------
+
+
+def generate_html(
+ messages: List[TranscriptEntry],
+ title: Optional[str] = None,
+ combined_transcript_link: Optional[str] = None,
+) -> str:
+ """Generate HTML from transcript messages using Jinja2 templates.
+
+ This is a convenience function that delegates to HtmlRenderer.generate.
+ """
+ return HtmlRenderer().generate(messages, title, combined_transcript_link)
+
+
+def generate_session_html(
+ messages: List[TranscriptEntry],
+ session_id: str,
+ title: Optional[str] = None,
+ cache_manager: Optional["CacheManager"] = None,
+) -> str:
+ """Generate HTML for a single session using Jinja2 templates."""
+ return HtmlRenderer().generate_session(messages, session_id, title, cache_manager)
+
+
+def generate_projects_index_html(
+ project_summaries: List[Dict[str, Any]],
+ from_date: Optional[str] = None,
+ to_date: Optional[str] = None,
+) -> str:
+ """Generate an index HTML page listing all projects using Jinja2 templates.
+
+ This is a convenience function that delegates to HtmlRenderer.generate_projects_index.
+ """
+ return HtmlRenderer().generate_projects_index(project_summaries, from_date, to_date)
diff --git a/claude_code_log/html/renderer_code.py b/claude_code_log/html/renderer_code.py
new file mode 100644
index 00000000..ab93f039
--- /dev/null
+++ b/claude_code_log/html/renderer_code.py
@@ -0,0 +1,330 @@
+#!/usr/bin/env python3
+"""Code rendering utilities for syntax highlighting and diffs.
+
+This module provides utilities for rendering source code with syntax highlighting
+(using Pygments) and rendering diffs with intra-line highlighting.
+"""
+
+import difflib
+import fnmatch
+import html
+import os
+import re
+from typing import Callable, List, Optional
+
+from pygments import highlight # type: ignore[reportUnknownVariableType]
+from pygments.lexers import TextLexer, get_lexer_by_name, get_all_lexers # type: ignore[reportUnknownVariableType]
+from pygments.formatters import HtmlFormatter # type: ignore[reportUnknownVariableType]
+from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType]
+
+from ..renderer_timings import timing_stat
+
+
+def _escape_html(text: str) -> str:
+ """Escape HTML special characters in text.
+
+ Also normalizes line endings (CRLF -> LF) to prevent double spacing in blocks.
+ """
+ normalized = text.replace("\r\n", "\n").replace("\r", "\n")
+ return html.escape(normalized)
+
+
+# Cache for Pygments lexer pattern matching
+_pattern_cache: Optional[dict[str, str]] = None
+_extension_cache: Optional[dict[str, str]] = None
+
+
+def _init_lexer_caches() -> tuple[dict[str, str], dict[str, str]]:
+ """Initialize lexer pattern and extension caches.
+
+ Returns:
+ Tuple of (pattern_cache, extension_cache)
+ """
+ global _pattern_cache, _extension_cache
+
+ if _pattern_cache is not None and _extension_cache is not None:
+ return _pattern_cache, _extension_cache
+
+ pattern_cache: dict[str, str] = {}
+ extension_cache: dict[str, str] = {}
+
+ # Use public API: get_all_lexers() returns (name, aliases, patterns, mimetypes) tuples
+ for name, aliases, patterns, mimetypes in get_all_lexers(): # type: ignore[reportUnknownVariableType]
+ if aliases and patterns:
+ # Use first alias as the lexer name
+ lexer_alias = aliases[0]
+ # Map each filename pattern to this lexer alias
+ for pattern in patterns:
+ pattern_lower = pattern.lower()
+ pattern_cache[pattern_lower] = lexer_alias
+ # Extract simple extension patterns (*.ext) for fast lookup
+ if (
+ pattern_lower.startswith("*.")
+ and "*" not in pattern_lower[2:]
+ and "?" not in pattern_lower[2:]
+ ):
+ ext = pattern_lower[2:] # Remove "*."
+ # Prefer first match for each extension
+ if ext not in extension_cache:
+ extension_cache[ext] = lexer_alias
+
+ _pattern_cache = pattern_cache
+ _extension_cache = extension_cache
+ return pattern_cache, extension_cache
+
+
+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
+ """
+ # Get caches (initialized lazily)
+ pattern_cache, extension_cache = _init_lexer_caches()
+
+ # Get basename for matching (patterns are like "*.py")
+ basename = os.path.basename(file_path).lower()
+
+ try:
+ # OPTIMIZATION: Try fast extension lookup first (O(1) dict lookup)
+ lexer_alias = None
+ if "." in basename:
+ ext = basename.split(".")[-1] # Get last extension (handles .tar.gz, etc.)
+ lexer_alias = extension_cache.get(ext)
+
+ # Fall back to pattern matching only if extension lookup failed
+ if lexer_alias is None:
+ for pattern, lex_alias in pattern_cache.items():
+ if fnmatch.fnmatch(basename, pattern):
+ lexer_alias = lex_alias
+ break
+
+ # Get lexer or use TextLexer as fallback
+ # Note: stripall=False preserves leading whitespace (important for code indentation)
+ if lexer_alias:
+ lexer = get_lexer_by_name(lexer_alias, stripall=False) # type: ignore[reportUnknownVariableType]
+ else:
+ lexer = TextLexer() # 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 with timing if enabled
+ with timing_stat("_pygments_timings"):
+ return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType]
+
+
+def truncate_highlighted_preview(highlighted_html: str, max_lines: int) -> str:
+ """Truncate Pygments highlighted HTML to first N lines.
+
+ HtmlFormatter(linenos="table") produces a single with two s:
+ LINE_NUMS
+ CODE
+
+ We truncate content within each tag to the first max_lines lines.
+
+ Args:
+ highlighted_html: Full Pygments-highlighted HTML
+ max_lines: Maximum number of lines to include in preview
+
+ Returns:
+ Truncated HTML with same structure but fewer lines
+ """
+
+ def truncate_pre_content(match: re.Match[str]) -> str:
+ """Truncate content inside a tag to max_lines."""
+ prefix, content, suffix = match.groups()
+ lines = content.split("\n")
+ truncated = "\n".join(lines[:max_lines])
+ return prefix + truncated + suffix
+
+ # Truncate linenos content (line numbers separated by newlines)
+ result = re.sub(
+ r'()(.*?)(
)',
+ truncate_pre_content,
+ highlighted_html,
+ flags=re.DOTALL,
+ )
+
+ # Truncate code content
+ result = re.sub(
+ r'(]*>)(.*?)(
)',
+ truncate_pre_content,
+ result,
+ flags=re.DOTALL,
+ )
+
+ return result
+
+
+def render_line_diff(
+ old_line: str, new_line: str, escape_fn: Optional[Callable[[str], str]] = None
+) -> str:
+ """Render a pair of changed lines with character-level highlighting.
+
+ Args:
+ old_line: The original line
+ new_line: The new line
+ escape_fn: Optional HTML escape function (defaults to internal _escape_html)
+
+ Returns:
+ HTML string with both lines and character-level diff highlighting
+ """
+ if escape_fn is None:
+ escape_fn = _escape_html
+
+ # 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_fn(chunk))
+ elif tag in ("delete", "replace"):
+ old_parts.append(
+ f"{escape_fn(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_fn(chunk))
+ elif tag in ("insert", "replace"):
+ new_parts.append(f"{escape_fn(chunk)}")
+ new_parts.append("")
+
+ return "".join(old_parts) + "".join(new_parts)
+
+
+def render_single_diff(
+ old_string: str, new_string: str, escape_fn: Optional[Callable[[str], str]] = None
+) -> str:
+ """Render a single diff between old_string and new_string.
+
+ Args:
+ old_string: The original content
+ new_string: The new content
+ escape_fn: Optional HTML escape function (defaults to internal _escape_html)
+
+ Returns:
+ HTML string with diff view and intra-line highlighting
+ """
+ if escape_fn is None:
+ escape_fn = _escape_html
+
+ # 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 = [""]
+
+ 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, escape_fn))
+
+ # Handle any unpaired lines
+ for old_line in removed_lines[len(added_lines) :]:
+ escaped = escape_fn(old_line.rstrip("\n"))
+ html_parts.append(
+ f"-{escaped}"
+ )
+
+ for new_line in added_lines[len(removed_lines) :]:
+ escaped = escape_fn(new_line.rstrip("\n"))
+ html_parts.append(
+ f"+{escaped}"
+ )
+ else:
+ # No corresponding addition - just removed
+ for old_line in removed_lines:
+ escaped = escape_fn(old_line.rstrip("\n"))
+ html_parts.append(
+ f"-{escaped}"
+ )
+
+ i = j
+
+ elif prefix == "+ ":
+ # Added line without corresponding removal
+ escaped = escape_fn(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_fn(content.rstrip("\n"))
+ html_parts.append(
+ f" {escaped}"
+ )
+ i += 1
+
+ html_parts.append("")
+ return "".join(html_parts)
diff --git a/claude_code_log/html/system_formatters.py b/claude_code_log/html/system_formatters.py
new file mode 100644
index 00000000..14182a25
--- /dev/null
+++ b/claude_code_log/html/system_formatters.py
@@ -0,0 +1,113 @@
+"""HTML formatters for system message content.
+
+This module formats SystemTranscriptEntry-derived content types to HTML.
+Part of the thematic formatter organization:
+- system_formatters.py: SystemContent, HookSummaryContent
+- user_formatters.py: (future) user message variants
+- assistant_formatters.py: (future) assistant message variants
+- tool_renderers.py: tool use/result content
+"""
+
+import html
+
+from .ansi_colors import convert_ansi_to_html
+from ..models import (
+ DedupNoticeContent,
+ HookSummaryContent,
+ SessionHeaderContent,
+ SystemContent,
+)
+
+
+def format_system_content(content: SystemContent) -> str:
+ """Format a system message with level-specific icon.
+
+ Args:
+ content: SystemContent with level and text
+
+ Returns:
+ HTML with icon and ANSI-converted text
+ """
+ level_icon = {"warning": "⚠️", "error": "❌", "info": "ℹ️"}.get(content.level, "ℹ️")
+ html_content = convert_ansi_to_html(content.text)
+ return f"{level_icon} {html_content}"
+
+
+def format_hook_summary_content(content: HookSummaryContent) -> str:
+ """Format a hook summary as collapsible details.
+
+ Shows a compact summary with expandable hook commands and error output.
+
+ Args:
+ content: HookSummaryContent with execution details
+
+ Returns:
+ HTML with collapsible details section
+ """
+ # Determine if this is a failure or just output
+ has_errors = bool(content.hook_errors)
+ summary_icon = "🪝"
+ summary_text = "Hook failed" if has_errors else "Hook output"
+
+ # Build the command section
+ command_html = ""
+ if content.hook_infos:
+ command_html = ''
+ for info in content.hook_infos:
+ # Truncate very long commands
+ cmd = info.command
+ display_cmd = cmd if len(cmd) <= 100 else cmd[:97] + "..."
+ command_html += f"{html.escape(display_cmd)}"
+ command_html += ""
+
+ # Build the error output section
+ error_html = ""
+ if content.hook_errors:
+ error_html = ''
+ for err in content.hook_errors:
+ # Convert ANSI codes in error output
+ formatted_err = convert_ansi_to_html(err)
+ error_html += f'{formatted_err}'
+ error_html += ""
+
+ return f"""
+{summary_icon} {summary_text}
+
+{command_html}
+{error_html}
+
+"""
+
+
+def format_session_header_content(content: SessionHeaderContent) -> str:
+ """Format a session header as HTML.
+
+ Args:
+ content: SessionHeaderContent with title, session_id, and optional summary
+
+ Returns:
+ HTML for the session header display
+ """
+ escaped_title = html.escape(content.title)
+ return escaped_title
+
+
+def format_dedup_notice_content(content: DedupNoticeContent) -> str:
+ """Format a deduplication notice as HTML.
+
+ Args:
+ content: DedupNoticeContent with notice text
+
+ Returns:
+ HTML for the dedup notice display
+ """
+ escaped_notice = html.escape(content.notice_text)
+ return f"{escaped_notice}
"
+
+
+__all__ = [
+ "format_system_content",
+ "format_hook_summary_content",
+ "format_session_header_content",
+ "format_dedup_notice_content",
+]
diff --git a/claude_code_log/templates/components/edit_diff_styles.css b/claude_code_log/html/templates/components/edit_diff_styles.css
similarity index 100%
rename from claude_code_log/templates/components/edit_diff_styles.css
rename to claude_code_log/html/templates/components/edit_diff_styles.css
diff --git a/claude_code_log/templates/components/filter_styles.css b/claude_code_log/html/templates/components/filter_styles.css
similarity index 100%
rename from claude_code_log/templates/components/filter_styles.css
rename to claude_code_log/html/templates/components/filter_styles.css
diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/html/templates/components/global_styles.css
similarity index 100%
rename from claude_code_log/templates/components/global_styles.css
rename to claude_code_log/html/templates/components/global_styles.css
diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css
similarity index 90%
rename from claude_code_log/templates/components/message_styles.css
rename to claude_code_log/html/templates/components/message_styles.css
index 2bc67bf0..e7a4f30d 100644
--- a/claude_code_log/templates/components/message_styles.css
+++ b/claude_code_log/html/templates/components/message_styles.css
@@ -39,14 +39,14 @@
font-weight: 500;
padding: 0.4em;
transition: all 0.2s ease;
- border-bottom: 2px solid;
+ border-bottom: 2px solid transparent;
background: linear-gradient(to bottom, #f8f8f844, #f0f0f0);
}
-/* Double-line effect when folded */
+/* Show border only when folded (content is hidden) */
.fold-bar-section.folded {
- border-bottom-style: double;
- border-bottom-width: 4px;
+ border-bottom-style: solid;
+ border-bottom-width: 2px;
}
.fold-bar-section:hover {
@@ -85,92 +85,86 @@
font-size: 0.9em;
}
-/* Border colors matching message types */
-.fold-bar[data-border-color="user"] .fold-bar-section,
-.fold-bar[data-border-color="user compacted"] .fold-bar-section,
-.fold-bar[data-border-color="user sidechain"] .fold-bar-section,
-.fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section {
+/* Border colors matching message types - only shown when folded */
+.fold-bar[data-border-color="user"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user compacted"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user sidechain"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user compacted sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--user-color);
}
-.fold-bar[data-border-color="user slash-command"] .fold-bar-section,
-.fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="user slash-command"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user slash-command sidechain"] .fold-bar-section.folded,
+.fold-bar[data-border-color="user command-output"] .fold-bar-section.folded {
border-bottom-color: var(--user-dimmed);
}
-.fold-bar[data-border-color="assistant"] .fold-bar-section,
-.fold-bar[data-border-color="assistant sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="assistant"] .fold-bar-section.folded,
+.fold-bar[data-border-color="assistant sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--assistant-color);
}
-.fold-bar[data-border-color="system"] .fold-bar-section,
-.fold-bar[data-border-color="system command-output"] .fold-bar-section {
+.fold-bar[data-border-color="system"] .fold-bar-section.folded {
border-bottom-color: var(--system-color);
}
-.fold-bar[data-border-color="system-warning"] .fold-bar-section {
+.fold-bar[data-border-color="system-warning"] .fold-bar-section.folded {
border-bottom-color: var(--system-warning-color);
}
-.fold-bar[data-border-color="system-error"] .fold-bar-section {
+.fold-bar[data-border-color="system-error"] .fold-bar-section.folded {
border-bottom-color: var(--system-error-color);
}
-.fold-bar[data-border-color="system-info"] .fold-bar-section {
+.fold-bar[data-border-color="system-info"] .fold-bar-section.folded {
border-bottom-color: var(--info-dimmed);
}
-.fold-bar[data-border-color="tool_use"] .fold-bar-section,
-.fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="tool_use"] .fold-bar-section.folded,
+.fold-bar[data-border-color="tool_use sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--tool-use-color);
}
-.fold-bar[data-border-color="tool_result"] .fold-bar-section,
-.fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="tool_result"] .fold-bar-section.folded,
+.fold-bar[data-border-color="tool_result sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--success-dimmed);
}
-.fold-bar[data-border-color="tool_result error"] .fold-bar-section,
-.fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="tool_result error"] .fold-bar-section.folded,
+.fold-bar[data-border-color="tool_result error sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--error-dimmed);
}
-.fold-bar[data-border-color="thinking"] .fold-bar-section,
-.fold-bar[data-border-color="thinking sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="thinking"] .fold-bar-section.folded,
+.fold-bar[data-border-color="thinking sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--assistant-dimmed);
}
-.fold-bar[data-border-color="image"] .fold-bar-section,
-.fold-bar[data-border-color="image sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="image"] .fold-bar-section.folded,
+.fold-bar[data-border-color="image sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--info-dimmed);
}
-.fold-bar[data-border-color="unknown"] .fold-bar-section,
-.fold-bar[data-border-color="unknown sidechain"] .fold-bar-section {
+.fold-bar[data-border-color="unknown"] .fold-bar-section.folded,
+.fold-bar[data-border-color="unknown sidechain"] .fold-bar-section.folded {
border-bottom-color: var(--neutral-dimmed);
}
-.fold-bar[data-border-color="bash-input"] .fold-bar-section {
+.fold-bar[data-border-color="bash-input"] .fold-bar-section.folded {
border-bottom-color: var(--user-color);
}
-.fold-bar[data-border-color="bash-output"] .fold-bar-section {
+.fold-bar[data-border-color="bash-output"] .fold-bar-section.folded {
border-bottom-color: var(--user-dimmed);
}
-.fold-bar[data-border-color="session-header"] .fold-bar-section {
+.fold-bar[data-border-color="session-header"] .fold-bar-section.folded {
border-bottom-color: var(--system-warning-color);
}
-/* Sidechain (sub-assistant) fold-bar styling */
-.sidechain .fold-bar-section {
- border-bottom-style: dashed;
- border-bottom-width: 2px;
-}
-
+/* Sidechain (sub-assistant) fold-bar styling - dashed border when folded */
.sidechain .fold-bar-section.folded {
border-bottom-style: dashed;
- border-bottom-width: 4px;
}
/* ========================================
@@ -261,17 +255,14 @@
}
/* Paired message styling */
-.message.paired-message {
+.message.pair_first {
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 {
+.message.pair_last {
margin-top: 0;
margin-bottom: 1em;
border-top-left-radius: 0;
@@ -279,8 +270,9 @@
border-top: 1px solid #00000011;
}
-.message.paired-message.pair_middle {
+.message.pair_middle {
margin-top: 0;
+ margin-bottom: 0;
border-radius: 0;
border-top: 1px solid #00000011;
border-bottom: none;
@@ -313,7 +305,7 @@
}
/* Dimmed assistant when paired with thinking */
-.assistant.paired-message {
+.assistant.pair_last {
border-left-color: var(--assistant-dimmed);
}
@@ -547,7 +539,7 @@
}
/* Full purple when thinking is paired (as pair_first) */
-.thinking.paired-message.pair_first {
+.thinking.pair_first {
border-left-color: var(--assistant-color);
}
@@ -642,6 +634,20 @@
font-family: var(--font-ui);
}
+.markdown table {
+ border-collapse: collapse;
+}
+
+.markdown td {
+ padding: .5em;
+ border-top: 1px solid #bbb;
+}
+
+.markdown th {
+ padding: .5em;
+ border-bottom: 1px solid #888;
+}
+
/* Code block styling */
pre > code {
display: block;
diff --git a/claude_code_log/templates/components/project_card_styles.css b/claude_code_log/html/templates/components/project_card_styles.css
similarity index 100%
rename from claude_code_log/templates/components/project_card_styles.css
rename to claude_code_log/html/templates/components/project_card_styles.css
diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/html/templates/components/pygments_styles.css
similarity index 100%
rename from claude_code_log/templates/components/pygments_styles.css
rename to claude_code_log/html/templates/components/pygments_styles.css
diff --git a/claude_code_log/templates/components/search.html b/claude_code_log/html/templates/components/search.html
similarity index 100%
rename from claude_code_log/templates/components/search.html
rename to claude_code_log/html/templates/components/search.html
diff --git a/claude_code_log/templates/components/search_inline.html b/claude_code_log/html/templates/components/search_inline.html
similarity index 100%
rename from claude_code_log/templates/components/search_inline.html
rename to claude_code_log/html/templates/components/search_inline.html
diff --git a/claude_code_log/templates/components/search_inline_script.html b/claude_code_log/html/templates/components/search_inline_script.html
similarity index 100%
rename from claude_code_log/templates/components/search_inline_script.html
rename to claude_code_log/html/templates/components/search_inline_script.html
diff --git a/claude_code_log/templates/components/search_results_panel.html b/claude_code_log/html/templates/components/search_results_panel.html
similarity index 100%
rename from claude_code_log/templates/components/search_results_panel.html
rename to claude_code_log/html/templates/components/search_results_panel.html
diff --git a/claude_code_log/templates/components/search_styles.css b/claude_code_log/html/templates/components/search_styles.css
similarity index 100%
rename from claude_code_log/templates/components/search_styles.css
rename to claude_code_log/html/templates/components/search_styles.css
diff --git a/claude_code_log/templates/components/session_nav.html b/claude_code_log/html/templates/components/session_nav.html
similarity index 100%
rename from claude_code_log/templates/components/session_nav.html
rename to claude_code_log/html/templates/components/session_nav.html
diff --git a/claude_code_log/templates/components/session_nav_styles.css b/claude_code_log/html/templates/components/session_nav_styles.css
similarity index 100%
rename from claude_code_log/templates/components/session_nav_styles.css
rename to claude_code_log/html/templates/components/session_nav_styles.css
diff --git a/claude_code_log/templates/components/timeline.html b/claude_code_log/html/templates/components/timeline.html
similarity index 100%
rename from claude_code_log/templates/components/timeline.html
rename to claude_code_log/html/templates/components/timeline.html
diff --git a/claude_code_log/templates/components/timeline_styles.css b/claude_code_log/html/templates/components/timeline_styles.css
similarity index 100%
rename from claude_code_log/templates/components/timeline_styles.css
rename to claude_code_log/html/templates/components/timeline_styles.css
diff --git a/claude_code_log/templates/components/timezone_converter.js b/claude_code_log/html/templates/components/timezone_converter.js
similarity index 100%
rename from claude_code_log/templates/components/timezone_converter.js
rename to claude_code_log/html/templates/components/timezone_converter.js
diff --git a/claude_code_log/templates/components/todo_styles.css b/claude_code_log/html/templates/components/todo_styles.css
similarity index 100%
rename from claude_code_log/templates/components/todo_styles.css
rename to claude_code_log/html/templates/components/todo_styles.css
diff --git a/claude_code_log/templates/index.html b/claude_code_log/html/templates/index.html
similarity index 100%
rename from claude_code_log/templates/index.html
rename to claude_code_log/html/templates/index.html
diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/html/templates/transcript.html
similarity index 96%
rename from claude_code_log/templates/transcript.html
rename to claude_code_log/html/templates/transcript.html
index e01e2300..8a256853 100644
--- a/claude_code_log/templates/transcript.html
+++ b/claude_code_log/html/templates/transcript.html
@@ -69,11 +69,11 @@ 🔍 Search & Filter
{{ render_session_nav(sessions, "toc") }}
{% endif %}
- {% for message in messages %}
+ {% for message, html_content in messages %}
{% if message.is_session_header %}
- Session: {{ message.content_html }}
+ Session: {{ html_content }}
{% if message.session_subtitle %}
{{
message.session_subtitle }} ({{message.session_id}})
@@ -101,19 +101,14 @@ 🔍 Search & Filter
{% endif %}
{% else %}
- {% set markdown = message.css_class in ['assistant', 'thinking', 'sidechain'] or (message.css_class and 'compacted' in message.css_class) %}
-
+ {%- set msg_css_class = css_class_from_message(message) %}
+ {% set markdown = message.type in ['assistant', 'thinking'] or message.modifiers.is_compacted %}
+
+ {% set msg_emoji = get_message_emoji(message) -%}
{% if message.message_title %}{%
if message.message_title == 'Memory' %}💭 {%
- elif message.css_class.startswith('user') %}🤷 {%
- elif message.css_class.startswith('assistant') %}🤖 {%
- elif message.css_class == 'system' %}⚙️ {%
- elif message.css_class.startswith('tool_use') and not starts_with_emoji(message.message_title) %}🛠️ {%
- elif message.css_class == 'tool_result error' %}🚨 {%
- elif message.css_class.startswith('tool_result') %}🧰 {%
- elif message.css_class.startswith('thinking') %}💭 {%
- elif message.css_class == 'image' %}🖼️ {% endif %}{{ message.message_title | safe }}{% endif %}
+ elif msg_emoji and (message.type != 'tool_use' or not starts_with_emoji(message.message_title)) %}{{ msg_emoji }} {% endif %}{{ message.message_title | safe }}{% endif %}
{{ message.formatted_timestamp }}
@@ -123,9 +118,9 @@ 🔍 Search & Filter
{% endif %}
- {{ message.content_html | safe }}
+ {{ html_content | safe }}
{% if message.has_children %}
-
+
{% if message.immediate_children_count == message.total_descendants_count %}
{# Same count = only one level, show single full-width button #}
diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py
new file mode 100644
index 00000000..35053562
--- /dev/null
+++ b/claude_code_log/html/tool_formatters.py
@@ -0,0 +1,950 @@
+"""HTML rendering functions for tool use and tool result content.
+
+This module contains all HTML formatters for specific tools:
+- AskUserQuestion tool (input + result)
+- ExitPlanMode tool (input + result)
+- TodoWrite tool
+- Read/Write/Edit/Multiedit tools
+- Bash tool
+- Task tool
+- Generic parameter table rendering
+- Tool use content dispatcher
+
+These formatters take tool-specific input/output data and generate
+HTML for display in transcripts.
+"""
+
+import json
+import re
+from typing import Any, Dict, List, Optional, cast
+
+from .utils import (
+ escape_html,
+ render_file_content_collapsible,
+ render_markdown_collapsible,
+)
+from ..models import (
+ AskUserQuestionInput,
+ AskUserQuestionItem,
+ BashInput,
+ EditInput,
+ EditOutput,
+ ExitPlanModeInput,
+ MultiEditInput,
+ ReadInput,
+ ReadOutput,
+ TaskInput,
+ TodoWriteInput,
+ ToolResultContent,
+ ToolUseContent,
+ WriteInput,
+)
+from .ansi_colors import convert_ansi_to_html
+from .renderer_code import render_single_diff
+
+
+# -- AskUserQuestion Tool -----------------------------------------------------
+
+
+def _render_question_item(q: AskUserQuestionItem) -> str:
+ """Render a single question item to HTML."""
+ html_parts: List[str] = ['']
+
+ # Header (if present)
+ if q.header:
+ escaped_header = escape_html(q.header)
+ html_parts.append(f'{escaped_header}')
+
+ # Question text with icon
+ question_text = escape_html(q.question)
+ html_parts.append(f'❓ {question_text}')
+
+ # Options (if present)
+ if q.options:
+ select_hint = "(select multiple)" if q.multiSelect else "(select one)"
+ html_parts.append(f'{select_hint}')
+ html_parts.append('')
+ for opt in q.options:
+ label = escape_html(opt.label)
+ if opt.description:
+ desc_html = f' — {escape_html(opt.description)}'
+ else:
+ desc_html = ""
+ html_parts.append(
+ f'- {label}{desc_html}
'
+ )
+ html_parts.append("
")
+
+ html_parts.append("") # Close question-block
+ return "".join(html_parts)
+
+
+def format_askuserquestion_content(ask_input: AskUserQuestionInput) -> str:
+ """Format AskUserQuestion tool use content with prominent question display.
+
+ Args:
+ ask_input: Typed AskUserQuestionInput with questions list and/or single question.
+
+ Handles multiple questions in a single tool use, each with optional header,
+ options (with label and description), and multiSelect flag.
+ """
+ # Build list of questions from both formats
+ questions: List[AskUserQuestionItem] = list(ask_input.questions)
+
+ # Handle single question format (legacy)
+ if not questions and ask_input.question:
+ questions.append(AskUserQuestionItem(question=ask_input.question))
+
+ if not questions:
+ return 'No question'
+
+ # Build HTML for all questions
+ html_parts: List[str] = ['']
+ for q in questions:
+ html_parts.append(_render_question_item(q))
+ html_parts.append("") # Close askuserquestion-content
+ return "".join(html_parts)
+
+
+def format_askuserquestion_result(content: str) -> str:
+ """Format AskUserQuestion tool result with styled question/answer pairs.
+
+ Parses the result format:
+ 'User has answered your questions: "Q1"="A1", "Q2"="A2". You can now continue...'
+
+ Returns HTML with styled Q&A blocks matching the input styling.
+ """
+ # Check if this is a successful answer
+ if not content.startswith("User has answered your question"):
+ # Return as-is for errors or unexpected format
+ return ""
+
+ # Extract the Q&A portion between the colon and the final sentence
+ # Pattern: 'User has answered your questions: "Q"="A", "Q"="A". You can now...'
+ match = re.match(
+ r"User has answered your questions?: (.+)\. You can now continue",
+ content,
+ re.DOTALL,
+ )
+ if not match:
+ return ""
+
+ qa_portion = match.group(1)
+
+ # Parse "Question"="Answer" pairs
+ # Pattern: "question text"="answer text"
+ qa_pattern = re.compile(r'"([^"]+)"="([^"]+)"')
+ pairs = qa_pattern.findall(qa_portion)
+
+ if not pairs:
+ return ""
+
+ # Build styled HTML
+ html_parts: List[str] = [
+ ''
+ ]
+
+ for question, answer in pairs:
+ escaped_q = escape_html(question)
+ escaped_a = escape_html(answer)
+ html_parts.append('')
+ html_parts.append(f'❓ {escaped_q}')
+ html_parts.append(f'✅ {escaped_a}')
+ html_parts.append("")
+
+ html_parts.append("")
+ return "".join(html_parts)
+
+
+# -- ExitPlanMode Tool --------------------------------------------------------
+
+
+def format_exitplanmode_content(exit_input: ExitPlanModeInput) -> str:
+ """Format ExitPlanMode tool use content with collapsible plan markdown.
+
+ Args:
+ exit_input: Typed ExitPlanModeInput with plan content.
+
+ Renders the plan markdown in a collapsible section, similar to Task tool results.
+ """
+ if not exit_input.plan:
+ return 'No plan'
+
+ return render_markdown_collapsible(exit_input.plan, "plan-content")
+
+
+def format_exitplanmode_result(content: str) -> str:
+ """Format ExitPlanMode tool result, truncating the redundant plan echo.
+
+ When a plan is approved, the result contains:
+ 1. A confirmation message
+ 2. Path to saved plan file
+ 3. "## Approved Plan:" followed by full plan text (redundant)
+
+ We truncate everything after "## Approved Plan:" to avoid duplication.
+ For error results (plan not approved), we keep the full content.
+ """
+ # Check if this is a successful approval
+ if "User has approved your plan" in content:
+ # Truncate at "## Approved Plan:"
+ marker = "## Approved Plan:"
+ marker_pos = content.find(marker)
+ if marker_pos > 0:
+ # Keep everything before the marker, strip trailing whitespace
+ return content[:marker_pos].rstrip()
+
+ # For errors or other cases, return as-is
+ return content
+
+
+# -- TodoWrite Tool -----------------------------------------------------------
+
+
+def format_todowrite_content(todo_input: TodoWriteInput) -> str:
+ """Format TodoWrite tool use content as a todo list.
+
+ Args:
+ todo_input: Typed TodoWriteInput with list of todo items.
+ """
+ if not todo_input.todos:
+ return """
+
+ No todos found
+
+ """
+
+ # Status emojis
+ status_emojis = {"pending": "⏳", "in_progress": "🔄", "completed": "✅"}
+
+ # Build todo list HTML - todos are typed TodoWriteItem objects
+ todo_items: List[str] = []
+ for todo in todo_input.todos:
+ todo_id = escape_html(todo.id) if todo.id else ""
+ content = escape_html(todo.content) if todo.content else ""
+ status = todo.status or "pending"
+ priority = todo.priority or "medium"
+ status_emoji = status_emojis.get(status, "⏳")
+
+ # CSS class for styling
+ item_class = f"todo-item {status} {priority}"
+
+ id_html = f'#{todo_id}' if todo.id else ""
+ todo_items.append(f"""
+
+ {status_emoji}
+ {content}
+ {id_html}
+
+ """)
+
+ todos_html = "".join(todo_items)
+
+ return f"""
+
+ {todos_html}
+
+ """
+
+
+# -- File Tools (Read/Write) --------------------------------------------------
+
+
+def format_read_tool_content(read_input: ReadInput) -> str: # noqa: ARG001
+ """Format Read tool use content showing file path.
+
+ Args:
+ read_input: Typed ReadInput with file_path, offset, and limit.
+
+ 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 ""
+
+
+# -- Tool Result Parsing (cat-n format) ---------------------------------------
+
+
+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
+ """
+ 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
+
+ # Join code lines and trim trailing reminder text
+ code_content = "\n".join(code_lines)
+ if system_reminder:
+ system_reminder = system_reminder.strip()
+
+ return (code_content, system_reminder, line_offset)
+
+
+def parse_read_output(content: str, file_path: str) -> Optional[ReadOutput]:
+ """Parse Read tool result into structured content.
+
+ Args:
+ content: Raw tool result string
+ file_path: Path to the file that was read
+
+ Returns:
+ ReadOutput if parsing succeeds, None otherwise
+ """
+ # 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
+
+ result = _parse_cat_n_snippet(lines)
+ if result is None:
+ return None
+
+ code_content, system_reminder, line_offset = result
+ num_lines = len(code_content.split("\n"))
+
+ return ReadOutput(
+ file_path=file_path,
+ content=code_content,
+ start_line=line_offset,
+ num_lines=num_lines,
+ total_lines=num_lines, # We don't know total from result
+ is_truncated=False, # Can't determine from result
+ system_reminder=system_reminder,
+ )
+
+
+def format_read_tool_result(output: ReadOutput) -> str:
+ """Format Read tool result as HTML with syntax highlighting.
+
+ Args:
+ output: Parsed ReadOutput
+
+ Returns:
+ HTML string with syntax-highlighted, collapsible file content
+ """
+ # Build system reminder suffix if present
+ suffix_html = ""
+ if output.system_reminder:
+ escaped_reminder = escape_html(output.system_reminder)
+ suffix_html = (
+ f"🤖 {escaped_reminder}"
+ )
+
+ return render_file_content_collapsible(
+ output.content,
+ output.file_path,
+ "read-tool-result",
+ linenostart=output.start_line,
+ suffix_html=suffix_html,
+ )
+
+
+def parse_edit_output(content: str, file_path: str) -> Optional[EditOutput]:
+ """Parse Edit tool result into structured content.
+
+ 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.
+
+ Args:
+ content: Raw tool result string
+ file_path: Path to the file that was edited
+
+ Returns:
+ EditOutput if parsing succeeds, None otherwise
+ """
+ # 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
+
+ return EditOutput(
+ file_path=file_path,
+ success=True, # If we got here, edit succeeded
+ diffs=[], # We don't have diff info from result
+ message=code_content,
+ start_line=line_offset,
+ )
+
+
+def format_edit_tool_result(output: EditOutput) -> str:
+ """Format Edit tool result as HTML with syntax highlighting.
+
+ Args:
+ output: Parsed EditOutput
+
+ Returns:
+ HTML string with syntax-highlighted, collapsible file content
+ """
+ return render_file_content_collapsible(
+ output.message, # message contains the code snippet
+ output.file_path,
+ "edit-tool-result",
+ linenostart=output.start_line,
+ )
+
+
+def format_write_tool_content(write_input: WriteInput) -> str:
+ """Format Write tool use content with Pygments syntax highlighting.
+
+ Args:
+ write_input: Typed WriteInput with file_path and content.
+ Note: File path is now shown in the header, so we skip it here.
+ """
+ return render_file_content_collapsible(
+ write_input.content, write_input.file_path, "write-tool-content"
+ )
+
+
+# -- Edit Tools (Edit/Multiedit) ----------------------------------------------
+
+
+def format_edit_tool_content(edit_input: EditInput) -> str:
+ """Format Edit tool use content as a diff view with intra-line highlighting.
+
+ Args:
+ edit_input: Typed EditInput with old_string, new_string, replace_all.
+ Note: File path is now shown in the header, so we skip it here.
+ """
+ html_parts = [""]
+
+ if edit_input.replace_all:
+ html_parts.append(
+ "🔄 Replace all occurrences"
+ )
+
+ # Use shared diff rendering helper
+ html_parts.append(render_single_diff(edit_input.old_string, edit_input.new_string))
+ html_parts.append("")
+
+ return "".join(html_parts)
+
+
+def format_multiedit_tool_content(multiedit_input: MultiEditInput) -> str:
+ """Format Multiedit tool use content showing multiple diffs.
+
+ Args:
+ multiedit_input: Typed MultiEditInput with file_path and list of edits.
+ """
+ escaped_path = escape_html(multiedit_input.file_path)
+
+ html_parts = [""]
+
+ # File path header
+ html_parts.append(f"📝 {escaped_path}")
+ html_parts.append(
+ f"Applying {len(multiedit_input.edits)} edits"
+ )
+
+ # Render each edit as a diff - edits are typed EditItem objects
+ for idx, edit in enumerate(multiedit_input.edits, 1):
+ html_parts.append(
+ f"Edit #{idx}"
+ )
+ html_parts.append(render_single_diff(edit.old_string, edit.new_string))
+ html_parts.append("")
+
+ html_parts.append("")
+ return "".join(html_parts)
+
+
+# -- Bash Tool ----------------------------------------------------------------
+
+
+def format_bash_tool_content(bash_input: BashInput) -> str:
+ """Format Bash tool use content in VS Code extension style.
+
+ Args:
+ bash_input: Typed BashInput with command, description, timeout, etc.
+ Note: Description is now shown in the header, so we skip it here.
+ """
+ escaped_command = escape_html(bash_input.command)
+
+ html_parts = [""]
+ html_parts.append(f"{escaped_command}")
+ html_parts.append("")
+
+ return "".join(html_parts)
+
+
+# -- Task Tool ----------------------------------------------------------------
+
+
+def format_task_tool_content(task_input: TaskInput) -> str:
+ """Format Task tool content with markdown-rendered prompt.
+
+ Args:
+ task_input: Typed TaskInput with prompt, subagent_type, etc.
+
+ Task tool spawns sub-agents. We render the prompt as the main content.
+ The sidechain user message (which would duplicate this prompt) is skipped.
+
+ For long prompts (>20 lines), the content is made collapsible with a
+ preview of the first few lines to keep the transcript vertically compact.
+ """
+ return render_markdown_collapsible(task_input.prompt, "task-prompt")
+
+
+# -- Tool Summary and Title ---------------------------------------------------
+
+
+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. Uses parsed_input for type-safe access.
+ """
+ parsed = tool_use.parsed_input
+
+ if isinstance(parsed, BashInput):
+ return parsed.description
+
+ if isinstance(parsed, (ReadInput, EditInput, WriteInput)):
+ return parsed.file_path if parsed.file_path else None
+
+ if isinstance(parsed, TaskInput):
+ return parsed.description if parsed.description else None
+
+ # No summary for other tools
+ return None
+
+
+def format_tool_use_title(tool_use: ToolUseContent) -> str:
+ """Generate the title HTML for a tool use message.
+
+ Returns HTML string for the message header, with tool name, icon,
+ and optional summary/metadata. Uses parsed_input for type-safe access.
+ """
+ escaped_name = escape_html(tool_use.name)
+ parsed = tool_use.parsed_input
+ summary = get_tool_summary(tool_use)
+
+ # TodoWrite: fixed title
+ if tool_use.name == "TodoWrite":
+ return "📝 Todo List"
+
+ # Task: show subagent_type and description
+ if isinstance(parsed, TaskInput):
+ escaped_subagent = (
+ escape_html(parsed.subagent_type) if parsed.subagent_type else ""
+ )
+ description = parsed.description
+
+ if description and parsed.subagent_type:
+ escaped_desc = escape_html(description)
+ return f"🔧 {escaped_name} {escaped_desc} ({escaped_subagent})"
+ elif description:
+ escaped_desc = escape_html(description)
+ return f"🔧 {escaped_name} {escaped_desc}"
+ elif parsed.subagent_type:
+ return f"🔧 {escaped_name} ({escaped_subagent})"
+ else:
+ return f"🔧 {escaped_name}"
+
+ # Edit/Write: use 📝 icon
+ if isinstance(parsed, (EditInput, WriteInput)):
+ if summary:
+ escaped_summary = escape_html(summary)
+ return (
+ f"📝 {escaped_name} {escaped_summary}"
+ )
+ else:
+ return f"📝 {escaped_name}"
+
+ # Read: use 📄 icon
+ if isinstance(parsed, ReadInput):
+ if summary:
+ escaped_summary = escape_html(summary)
+ return (
+ f"📄 {escaped_name} {escaped_summary}"
+ )
+ else:
+ return f"📄 {escaped_name}"
+
+ # Other tools: append summary if present
+ if summary:
+ escaped_summary = escape_html(summary)
+ return f"{escaped_name} {escaped_summary}"
+
+ return escaped_name
+
+
+# -- Generic Parameter Table --------------------------------------------------
+
+
+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, ensure_ascii=False) # 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"""
+
+ {escaped_key}
+ {value_html}
+
+ """)
+
+ html_parts.append("
")
+ return "".join(html_parts)
+
+
+# -- Tool Use Dispatcher ------------------------------------------------------
+
+
+def format_tool_use_content(tool_use: ToolUseContent) -> str:
+ """Format tool use content as HTML.
+
+ Uses parsed_input which handles lenient parsing at the model layer,
+ then dispatches to specialized formatters based on type.
+ """
+ parsed = tool_use.parsed_input
+
+ # Dispatch based on parsed type (lenient parsing happens in parsed_input)
+ if isinstance(parsed, TodoWriteInput):
+ return format_todowrite_content(parsed)
+
+ if isinstance(parsed, BashInput):
+ return format_bash_tool_content(parsed)
+
+ if isinstance(parsed, EditInput):
+ return format_edit_tool_content(parsed)
+
+ if isinstance(parsed, MultiEditInput):
+ return format_multiedit_tool_content(parsed)
+
+ if isinstance(parsed, WriteInput):
+ return format_write_tool_content(parsed)
+
+ if isinstance(parsed, TaskInput):
+ return format_task_tool_content(parsed)
+
+ if isinstance(parsed, ReadInput):
+ return format_read_tool_content(parsed)
+
+ if isinstance(parsed, AskUserQuestionInput):
+ return format_askuserquestion_content(parsed)
+
+ if isinstance(parsed, ExitPlanModeInput):
+ return format_exitplanmode_content(parsed)
+
+ # Default: render as key/value table using shared renderer
+ return render_params_table(tool_use.input)
+
+
+# -- Tool Result Content Formatter -------------------------------------------
+
+
+def _looks_like_bash_output(content: str) -> bool:
+ """Check if content looks like it's from a Bash tool based on common patterns."""
+ if not content:
+ return False
+
+ # Check for ANSI escape sequences
+ if "\x1b[" in content:
+ return True
+
+ # Check for common bash/terminal patterns
+ bash_indicators = [
+ "$ ", # Shell prompt
+ "❯ ", # Modern shell prompt
+ "> ", # Shell continuation
+ "\n+ ", # Bash -x output
+ "bash: ", # Bash error messages
+ "/bin/bash", # Bash path
+ "command not found", # Common bash error
+ "Permission denied", # Common bash error
+ "No such file or directory", # Common bash error
+ ]
+
+ # Check for file path patterns that suggest command output
+ if re.search(r"/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*", content): # Unix-style paths
+ return True
+
+ # Check for common command output patterns
+ if any(indicator in content for indicator in bash_indicators):
+ return True
+
+ return False
+
+
+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", "Task")
+ """
+ # Handle both string and structured content
+ if isinstance(tool_result.content, str):
+ raw_content = tool_result.content
+ has_images = False
+ image_html_parts: List[str] = []
+ else:
+ # Content is a list of structured items, extract text and images
+ content_parts: List[str] = []
+ image_html_parts: List[str] = []
+ for item in tool_result.content:
+ item_type = item.get("type")
+ if item_type == "text":
+ text_value = item.get("text")
+ if isinstance(text_value, str):
+ content_parts.append(text_value)
+ elif item_type == "image":
+ # Handle image content within tool results
+ source = cast(Dict[str, Any], item.get("source", {}))
+ if source:
+ media_type: str = str(source.get("media_type", "image/png"))
+ data: str = str(source.get("data", ""))
+ if data:
+ data_url = f"data:{media_type};base64,{data}"
+ image_html_parts.append(
+ f'
'
+ )
+ 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
+ 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:
+ read_output = parse_read_output(raw_content, file_path)
+ if read_output:
+ return format_read_tool_result(read_output)
+
+ # Try to parse as Edit tool result if file_path is provided
+ if file_path and tool_name == "Edit" and not has_images:
+ edit_output = parse_edit_output(raw_content, file_path)
+ if edit_output:
+ return format_edit_tool_result(edit_output)
+
+ # Special handling for Task tool: render result as markdown with Pygments (agent's final message)
+ # Deduplication is now handled retroactively by replacing the sub-assistant content
+ if tool_name == "Task" and not has_images:
+ return render_markdown_collapsible(raw_content, "task-result")
+
+ # Special handling for ExitPlanMode tool: truncate redundant plan echo on success
+ if tool_name == "ExitPlanMode" and not has_images:
+ processed_content = format_exitplanmode_result(raw_content)
+ escaped_content = escape_html(processed_content)
+ return f"{escaped_content}"
+
+ # Special handling for AskUserQuestion tool: render Q&A pairs with styling
+ if tool_name == "AskUserQuestion" and not has_images:
+ styled_result = format_askuserquestion_result(raw_content)
+ if styled_result:
+ return styled_result
+ # Fall through to default handling if parsing fails
+
+ # 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):
+ escaped_content = convert_ansi_to_html(raw_content)
+ else:
+ escaped_content = escape_html(raw_content)
+
+ # Build final HTML based on content length and presence of images
+ if has_images:
+ # Combine text and images
+ text_html = f"{escaped_content}" if escaped_content else ""
+ images_html = "".join(image_html_parts)
+ combined_content = f"{text_html}{images_html}"
+
+ # Always make collapsible when images are present
+ preview_text = "Text and image content"
+ return f"""
+
+
+ {preview_text}
+
+
+ {combined_content}
+
+
+ """
+ else:
+ # Text-only content (existing behavior)
+ # For simple content, show directly without collapsible wrapper
+ if len(escaped_content) <= 200:
+ return f"{escaped_content}"
+
+ # For longer content, use collapsible details but no extra wrapper
+ preview_text = escaped_content[:200] + "..."
+ return f"""
+
+
+ {preview_text}
+
+
+ {escaped_content}
+
+
+ """
+
+
+# -- Public Exports -----------------------------------------------------------
+
+__all__ = [
+ # AskUserQuestion
+ "format_askuserquestion_content",
+ "format_askuserquestion_result",
+ # ExitPlanMode
+ "format_exitplanmode_content",
+ "format_exitplanmode_result",
+ # TodoWrite
+ "format_todowrite_content",
+ # File tools (input)
+ "format_read_tool_content",
+ "format_write_tool_content",
+ # File tools (output/result)
+ "parse_read_output",
+ "format_read_tool_result",
+ "parse_edit_output",
+ "format_edit_tool_result",
+ # Edit tools
+ "format_edit_tool_content",
+ "format_multiedit_tool_content",
+ # Bash
+ "format_bash_tool_content",
+ # Task
+ "format_task_tool_content",
+ # Tool summary and title
+ "get_tool_summary",
+ "format_tool_use_title",
+ # Generic
+ "render_params_table",
+ # Dispatcher
+ "format_tool_use_content",
+ # Tool result
+ "format_tool_result_content",
+]
diff --git a/claude_code_log/html/user_formatters.py b/claude_code_log/html/user_formatters.py
new file mode 100644
index 00000000..21528208
--- /dev/null
+++ b/claude_code_log/html/user_formatters.py
@@ -0,0 +1,365 @@
+"""HTML formatters for user message content.
+
+This module formats non-tool user message content types to HTML.
+Part of the thematic formatter organization:
+- system_formatters.py: SystemContent, HookSummaryContent
+- user_formatters.py: SlashCommandContent, CommandOutputContent, etc.
+- assistant_formatters.py: (future) assistant message variants
+- tool_formatters.py: tool use/result content
+"""
+
+from typing import List
+
+import mistune
+
+from .ansi_colors import convert_ansi_to_html
+from ..models import (
+ BashInputContent,
+ BashOutputContent,
+ CommandOutputContent,
+ CompactedSummaryContent,
+ IdeDiagnostic,
+ IdeNotificationContent,
+ IdeOpenedFile,
+ IdeSelection,
+ SlashCommandContent,
+ UserMemoryContent,
+ UserTextContent,
+)
+from .tool_formatters import render_params_table
+from .utils import escape_html, render_collapsible_code, render_markdown_collapsible
+
+
+# =============================================================================
+# Formatting Functions
+# =============================================================================
+
+
+def format_slash_command_content(content: SlashCommandContent) -> str:
+ """Format slash command content as HTML.
+
+ Args:
+ content: SlashCommandContent with command name, args, and contents
+
+ Returns:
+ HTML string for the slash command display
+ """
+ escaped_command_name = escape_html(content.command_name)
+ escaped_command_args = escape_html(content.command_args)
+
+ # Format the command contents with proper line breaks
+ formatted_contents = content.command_contents.replace("\\n", "\n")
+ escaped_command_contents = escape_html(formatted_contents)
+
+ # Build the content HTML - command name is the primary content
+ content_parts: List[str] = [f"{escaped_command_name}"]
+ if content.command_args:
+ content_parts.append(f"Args: {escaped_command_args}")
+ if content.command_contents:
+ lines = escaped_command_contents.splitlines()
+ line_count = len(lines)
+ if line_count <= 12:
+ # Short content, show inline
+ details_html = (
+ f"Content:{escaped_command_contents}"
+ )
+ else:
+ # Long content, make collapsible
+ preview = "\n".join(lines[:5])
+ collapsible = render_collapsible_code(
+ f"{preview}",
+ f"{escaped_command_contents}",
+ line_count,
+ )
+ details_html = f"Content:{collapsible}"
+ content_parts.append(details_html)
+
+ return "
".join(content_parts)
+
+
+def format_command_output_content(content: CommandOutputContent) -> str:
+ """Format command output content as HTML.
+
+ Args:
+ content: CommandOutputContent with stdout and is_markdown flag
+
+ Returns:
+ HTML string for the command output display
+ """
+ if content.is_markdown:
+ # Render as markdown
+ markdown_html = mistune.html(content.stdout)
+ return f"{markdown_html}"
+ else:
+ # Convert ANSI codes to HTML for colored display
+ html_content = convert_ansi_to_html(content.stdout)
+ # Use to preserve formatting and line breaks
+ return f"{html_content}"
+
+
+def format_bash_input_content(content: BashInputContent) -> str:
+ """Format bash input content as HTML.
+
+ Args:
+ content: BashInputContent with the bash command
+
+ Returns:
+ HTML string for the bash input display
+ """
+ escaped_command = escape_html(content.command)
+ return (
+ f"❯ "
+ f"{escaped_command}"
+ )
+
+
+def format_bash_output_content(
+ content: BashOutputContent,
+ collapse_threshold: int = 10,
+ preview_lines: int = 3,
+) -> str:
+ """Format bash output content as HTML.
+
+ Args:
+ content: BashOutputContent with stdout and/or stderr
+ collapse_threshold: Number of lines before output becomes collapsible
+ preview_lines: Number of preview lines to show when collapsed
+
+ Returns:
+ HTML string for the bash output display
+ """
+ output_parts: List[tuple[str, str, int, str]] = []
+ total_lines = 0
+
+ if content.stdout:
+ escaped_stdout = convert_ansi_to_html(content.stdout)
+ stdout_lines = content.stdout.count("\n") + 1
+ total_lines += stdout_lines
+ output_parts.append(("stdout", escaped_stdout, stdout_lines, content.stdout))
+
+ if content.stderr:
+ escaped_stderr = convert_ansi_to_html(content.stderr)
+ stderr_lines = content.stderr.count("\n") + 1
+ total_lines += stderr_lines
+ output_parts.append(("stderr", escaped_stderr, stderr_lines, content.stderr))
+
+ if not output_parts:
+ # Empty output
+ return (
+ "(no output)"
+ )
+
+ # Build the HTML parts
+ html_parts: List[str] = []
+ for output_type, escaped_content, _, _ in output_parts:
+ css_name = f"bash-{output_type}"
+ html_parts.append(f"{escaped_content}")
+
+ full_html = "".join(html_parts)
+
+ # Wrap in collapsible if output is large
+ if total_lines > collapse_threshold:
+ # Create preview (first few lines)
+ first_output = output_parts[0]
+ raw_preview = "\n".join(first_output[3].split("\n")[:preview_lines])
+ preview_html = escape_html(raw_preview)
+ if total_lines > preview_lines:
+ preview_html += "\n..."
+
+ return f"""
+
+ {total_lines} lines
+ {preview_html}
+
+ {full_html}
+ """
+
+ return full_html
+
+
+def format_user_text_content(text: str) -> str:
+ """Format plain user text content as HTML.
+
+ User text is displayed as-is in preformatted blocks to preserve
+ formatting and whitespace.
+
+ Args:
+ text: The raw user message text
+
+ Returns:
+ HTML string with escaped text in a pre tag
+ """
+ escaped_text = escape_html(text)
+ return f"{escaped_text}"
+
+
+def format_user_text_model_content(content: UserTextContent) -> str:
+ """Format UserTextContent model as HTML.
+
+ Handles user text with optional IDE notifications, compacted summaries,
+ and memory input markers.
+
+ Args:
+ content: UserTextContent with text and optional flags/notifications
+
+ Returns:
+ HTML string combining IDE notifications and main text content
+ """
+ parts: List[str] = []
+
+ # Add IDE notifications first if present
+ if content.ide_notifications:
+ notifications = format_ide_notification_content(content.ide_notifications)
+ parts.extend(notifications)
+
+ # Format main text content based on type
+ if content.is_compacted:
+ # Render compacted summaries as markdown
+ text_html = render_markdown_collapsible(
+ content.text, "compacted-summary", line_threshold=20
+ )
+ elif content.is_memory_input:
+ # Render memory input as markdown
+ text_html = render_markdown_collapsible(
+ content.text, "user-memory", line_threshold=20
+ )
+ else:
+ # Regular user text as preformatted
+ text_html = format_user_text_content(content.text)
+
+ parts.append(text_html)
+ return "\n".join(parts)
+
+
+def format_compacted_summary_content(content: CompactedSummaryContent) -> str:
+ """Format compacted session summary content as HTML.
+
+ Compacted summaries are rendered as collapsible markdown since they
+ contain structured summary text generated by Claude.
+
+ Args:
+ content: CompactedSummaryContent with summary text
+
+ Returns:
+ HTML string with collapsible markdown rendering
+ """
+ return render_markdown_collapsible(
+ content.summary_text,
+ "compacted-summary",
+ line_threshold=30,
+ preview_line_count=10,
+ )
+
+
+def format_user_memory_content(content: UserMemoryContent) -> str:
+ """Format user memory input content as HTML.
+
+ User memory content (from CLAUDE.md etc.) is rendered as preformatted text
+ to preserve the original formatting.
+
+ Args:
+ content: UserMemoryContent with memory text
+
+ Returns:
+ HTML string with escaped text in a pre tag
+ """
+ escaped_text = escape_html(content.memory_text)
+ return f"{escaped_text}"
+
+
+def _format_opened_file(opened_file: IdeOpenedFile) -> str:
+ """Format a single IDE opened file notification as HTML."""
+ escaped_content = escape_html(opened_file.content)
+ return f"🤖 {escaped_content}"
+
+
+def _format_selection(selection: IdeSelection) -> str:
+ """Format a single IDE selection notification as HTML."""
+ escaped_content = escape_html(selection.content)
+
+ # For large selections, make them collapsible
+ if len(selection.content) > 200:
+ preview = escape_html(selection.content[:150]) + "..."
+ return f"""
+
+
+ 📝 {preview}
+ {escaped_content}
+
+
+ """
+ else:
+ return f"📝 {escaped_content}"
+
+
+def _format_diagnostic(diagnostic: IdeDiagnostic) -> List[str]:
+ """Format a single IDE diagnostic as HTML (may produce multiple notifications)."""
+ notifications: List[str] = []
+
+ if diagnostic.diagnostics:
+ # Parsed JSON diagnostics - render each as a table
+ for diag_item in diagnostic.diagnostics:
+ table_html = render_params_table(diag_item)
+ notification_html = (
+ f""
+ f"⚠️ IDE Diagnostic
{table_html}"
+ f""
+ )
+ notifications.append(notification_html)
+ elif diagnostic.raw_content:
+ # JSON parsing failed, render as plain text
+ escaped_content = escape_html(diagnostic.raw_content[:200])
+ notification_html = (
+ f"🤖 IDE Diagnostics (parse error)
"
+ f"{escaped_content}..."
+ )
+ notifications.append(notification_html)
+
+ return notifications
+
+
+def format_ide_notification_content(content: IdeNotificationContent) -> List[str]:
+ """Format IDE notification content as HTML.
+
+ Takes structured IdeNotificationContent and returns a list of HTML
+ notification strings.
+
+ Args:
+ content: IdeNotificationContent with opened_files, selections, diagnostics
+
+ Returns:
+ List of HTML notification strings
+ """
+ notifications: List[str] = []
+
+ # Format opened files
+ for opened_file in content.opened_files:
+ notifications.append(_format_opened_file(opened_file))
+
+ # Format selections
+ for selection in content.selections:
+ notifications.append(_format_selection(selection))
+
+ # Format diagnostics (may produce multiple notifications per diagnostic)
+ for diagnostic in content.diagnostics:
+ notifications.extend(_format_diagnostic(diagnostic))
+
+ return notifications
+
+
+# =============================================================================
+# Public Exports
+# =============================================================================
+
+__all__ = [
+ # Formatting functions
+ "format_slash_command_content",
+ "format_command_output_content",
+ "format_bash_input_content",
+ "format_bash_output_content",
+ "format_user_text_content",
+ "format_user_text_model_content",
+ "format_compacted_summary_content",
+ "format_user_memory_content",
+ "format_ide_notification_content",
+]
diff --git a/claude_code_log/html/utils.py b/claude_code_log/html/utils.py
new file mode 100644
index 00000000..fdd8c379
--- /dev/null
+++ b/claude_code_log/html/utils.py
@@ -0,0 +1,352 @@
+"""HTML-specific rendering utilities.
+
+This module contains all HTML generation code:
+- CSS class computation from message type and modifiers
+- Message emoji generation
+- HTML escaping and markdown rendering
+- Collapsible content rendering
+- Tool-specific HTML formatters
+- Message content HTML rendering
+- Template environment management
+
+The functions here transform format-neutral TemplateMessage data into
+HTML-specific output.
+"""
+
+import html
+from pathlib import Path
+from typing import Any, Optional, TYPE_CHECKING
+
+import mistune
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+from .renderer_code import highlight_code_with_pygments, truncate_highlighted_preview
+from ..renderer_timings import timing_stat
+
+if TYPE_CHECKING:
+ from ..renderer import TemplateMessage
+
+
+# -- CSS and Message Display --------------------------------------------------
+
+
+def css_class_from_message(msg: "TemplateMessage") -> str:
+ """Generate CSS class string from message type and modifiers.
+
+ This reconstructs the original css_class format for backward
+ compatibility with existing CSS and JavaScript.
+
+ The order of classes follows the original pattern:
+ 1. Message type (required)
+ 2. Modifier flags in order: slash-command, command-output, compacted,
+ error, steering, sidechain
+ 3. System level suffix (e.g., "system-info", "system-warning")
+
+ Args:
+ msg: The template message to generate CSS classes for
+
+ Returns:
+ Space-separated CSS class string (e.g., "user slash-command sidechain")
+ """
+ parts = [msg.type]
+
+ mods = msg.modifiers
+ if mods.is_slash_command:
+ parts.append("slash-command")
+ if mods.is_command_output:
+ parts.append("command-output")
+ if mods.is_compacted:
+ parts.append("compacted")
+ if mods.is_error:
+ parts.append("error")
+ if mods.is_steering:
+ parts.append("steering")
+ if mods.is_sidechain:
+ parts.append("sidechain")
+ if mods.system_level:
+ parts.append(f"system-{mods.system_level}")
+
+ return " ".join(parts)
+
+
+def get_message_emoji(msg: "TemplateMessage") -> str:
+ """Return appropriate emoji for message type.
+
+ Args:
+ msg: The template message to get emoji for
+
+ Returns:
+ Emoji string for the message type, or empty string if no emoji
+ """
+ msg_type = msg.type
+
+ if msg_type == "session_header":
+ return "📋"
+ elif msg_type == "user":
+ return "🤷"
+ elif msg_type == "assistant":
+ return "🤖"
+ elif msg_type == "system":
+ return "⚙️"
+ elif msg_type == "tool_use":
+ return "🛠️"
+ elif msg_type == "tool_result":
+ if msg.modifiers.is_error:
+ return "🚨"
+ return "🧰"
+ elif msg_type == "thinking":
+ return "💭"
+ elif msg_type == "image":
+ return "🖼️"
+ return ""
+
+
+# -- HTML Utilities -----------------------------------------------------------
+
+
+def escape_html(text: str) -> str:
+ """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_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,
+ )
+ # Track Pygments timing if enabled
+ with timing_stat("_pygments_timings"):
+ 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 with Pygments syntax highlighting."""
+ # Track markdown rendering time if enabled
+ with timing_stat("_markdown_timings"):
+ # Configure mistune with GitHub-flavored markdown features
+ renderer = mistune.create_markdown(
+ plugins=[
+ "strikethrough",
+ "footnotes",
+ "table",
+ "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)
+ )
+ return str(renderer(text))
+
+
+# -- Collapsible Content Rendering --------------------------------------------
+
+
+def render_collapsible_code(
+ preview_html: str,
+ full_html: str,
+ line_count: int,
+ is_markdown: bool = False,
+) -> str:
+ """Render a collapsible code/content block with preview.
+
+ Creates a details element with a line count badge and preview content
+ that expands to show the full content.
+
+ Args:
+ preview_html: HTML content to show in the collapsed summary
+ full_html: HTML content to show when expanded
+ line_count: Number of lines (shown in the badge)
+ is_markdown: If True, adds 'markdown' class to preview and full content divs
+
+ Returns:
+ HTML string with collapsible details element
+ """
+ markdown_class = " markdown" if is_markdown else ""
+ return f"""
+
+ {line_count} lines
+ {preview_html}
+
+ {full_html}
+ """
+
+
+def render_markdown_collapsible(
+ raw_content: str,
+ css_class: str,
+ line_threshold: int = 20,
+ preview_line_count: int = 5,
+) -> str:
+ """Render markdown content, making it collapsible if it exceeds a line threshold.
+
+ For long content, creates a collapsible details element with a preview.
+ For short content, renders inline with the specified CSS class.
+
+ Args:
+ raw_content: The raw text content to render as markdown
+ css_class: CSS class for the wrapper div (e.g., "task-prompt", "task-result")
+ line_threshold: Number of lines above which content becomes collapsible (default 20)
+ preview_line_count: Number of lines to show in the preview (default 5)
+
+ Returns:
+ HTML string with rendered markdown, optionally wrapped in collapsible details
+ """
+ rendered_html = render_markdown(raw_content)
+
+ lines = raw_content.splitlines()
+ if len(lines) <= line_threshold:
+ # Short content, show inline
+ return f'{rendered_html}'
+
+ # Long content - make collapsible with rendered preview
+ preview_lines = lines[:preview_line_count]
+ preview_text = "\n".join(preview_lines)
+ if len(lines) > preview_line_count:
+ preview_text += "\n\n..."
+ # Render truncated markdown (produces valid HTML with proper tag closure)
+ preview_html = render_markdown(preview_text)
+
+ collapsible = render_collapsible_code(
+ preview_html, rendered_html, len(lines), is_markdown=True
+ )
+ return f'{collapsible}'
+
+
+def render_file_content_collapsible(
+ code_content: str,
+ file_path: str,
+ css_class: str,
+ linenostart: int = 1,
+ line_threshold: int = 12,
+ preview_line_count: int = 5,
+ suffix_html: str = "",
+) -> str:
+ """Render file content with syntax highlighting, collapsible if long.
+
+ Highlights code using Pygments and wraps in a collapsible details element
+ if the content exceeds the line threshold. Uses preview truncation from
+ already-highlighted HTML to avoid double Pygments calls.
+
+ Args:
+ code_content: The raw code content to highlight
+ file_path: File path for syntax detection (extension-based)
+ css_class: CSS class for the wrapper div (e.g., 'write-tool-content')
+ linenostart: Starting line number for Pygments (default 1)
+ line_threshold: Number of lines above which content becomes collapsible
+ preview_line_count: Number of lines to show in the preview
+ suffix_html: Optional HTML to append after the code (inside wrapper div)
+
+ Returns:
+ HTML string with highlighted code, collapsible if >line_threshold lines
+ """
+ # Highlight code with Pygments (single call)
+ highlighted_html = highlight_code_with_pygments(
+ code_content, file_path, linenostart=linenostart
+ )
+
+ html_parts = [f""]
+
+ lines = code_content.split("\n")
+ if len(lines) > line_threshold:
+ # Extract preview from already-highlighted HTML (avoids double highlighting)
+ preview_html = truncate_highlighted_preview(
+ highlighted_html, preview_line_count
+ )
+ html_parts.append(
+ render_collapsible_code(preview_html, highlighted_html, len(lines))
+ )
+ else:
+ # Show directly without collapsible
+ html_parts.append(highlighted_html)
+
+ if suffix_html:
+ html_parts.append(suffix_html)
+
+ html_parts.append("")
+ return "".join(html_parts)
+
+
+# -- Template Environment -----------------------------------------------------
+
+
+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_template_environment() -> Environment:
+ """Get Jinja2 template environment for HTML rendering.
+
+ Creates a Jinja2 environment configured with:
+ - Template loading from the templates directory
+ - HTML auto-escaping
+ - Custom template filters/functions (starts_with_emoji)
+
+ Returns:
+ Configured Jinja2 Environment
+ """
+ templates_dir = Path(__file__).parent / "templates"
+ 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
diff --git a/claude_code_log/models.py b/claude_code_log/models.py
index 8b40c10a..135983e1 100644
--- a/claude_code_log/models.py
+++ b/claude_code_log/models.py
@@ -3,20 +3,642 @@
Enhanced to leverage official Anthropic types where beneficial.
"""
-from typing import Any, List, Union, Optional, Dict, Literal, cast
-from pydantic import BaseModel
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, List, Union, Optional, Dict, Literal
from anthropic.types import Message as AnthropicMessage
from anthropic.types import StopReason
from anthropic.types import Usage as AnthropicUsage
from anthropic.types.content_block import ContentBlock
+from pydantic import BaseModel
-class TodoItem(BaseModel):
- id: str
+class MessageType(str, Enum):
+ """Primary message type classification.
+
+ This enum covers both JSONL entry types and rendering types.
+ Using str as base class maintains backward compatibility with string comparisons.
+
+ JSONL Entry Types (from transcript files):
+ - USER, ASSISTANT, SYSTEM, SUMMARY, QUEUE_OPERATION
+
+ Rendering Types (derived during processing):
+ - TOOL_USE, TOOL_RESULT, THINKING, IMAGE
+ - BASH_INPUT, BASH_OUTPUT
+ - SESSION_HEADER, UNKNOWN
+ """
+
+ # JSONL entry types
+ USER = "user"
+ ASSISTANT = "assistant"
+ SYSTEM = "system"
+ SUMMARY = "summary"
+ QUEUE_OPERATION = "queue-operation"
+
+ # Rendering/display types (derived from content)
+ TOOL_USE = "tool_use"
+ TOOL_RESULT = "tool_result"
+ THINKING = "thinking"
+ IMAGE = "image"
+ BASH_INPUT = "bash-input"
+ BASH_OUTPUT = "bash-output"
+ SESSION_HEADER = "session-header"
+ UNKNOWN = "unknown"
+
+ # System subtypes (for css_class)
+ SYSTEM_INFO = "system-info"
+ SYSTEM_WARNING = "system-warning"
+ SYSTEM_ERROR = "system-error"
+
+
+@dataclass
+class MessageModifiers:
+ """Semantic modifiers that affect message display.
+
+ These are format-neutral flags that renderers can use to determine
+ how to display a message. HTML renderer converts these to CSS classes,
+ text renderer might use them for indentation or formatting.
+
+ The modifiers capture traits that were previously encoded in the
+ css_class string (e.g., "user sidechain slash-command").
+ """
+
+ is_sidechain: bool = False
+ is_slash_command: bool = False
+ is_command_output: bool = False
+ is_compacted: bool = False
+ is_error: bool = False
+ is_steering: bool = False
+ # System message level (mutually exclusive: info, warning, error, hook)
+ system_level: Optional[str] = None
+
+
+# =============================================================================
+# Message Content Models
+# =============================================================================
+# Structured content models for format-neutral message representation.
+# These replace the direct HTML generation in renderer.py, allowing different
+# renderers (HTML, text, etc.) to format the content appropriately.
+
+
+class MessageContent:
+ """Base class for structured message content.
+
+ Subclasses represent specific content types that renderers can format
+ appropriately for their output format.
+
+ Note: This is a plain class (not dataclass) to allow Pydantic BaseModel
+ subclasses like ToolUseContent and ImageContent to inherit from it.
+ """
+
+ pass
+
+
+@dataclass
+class SystemContent(MessageContent):
+ """System message with level indicator.
+
+ Used for info, warning, and error system messages.
+ """
+
+ level: str # "info", "warning", "error"
+ text: str # Raw text content (may contain ANSI codes)
+
+
+@dataclass
+class HookInfo:
+ """Information about a single hook execution."""
+
+ command: str
+ # Could add more fields like exit_code, duration, etc.
+
+
+@dataclass
+class HookSummaryContent(MessageContent):
+ """Hook execution summary.
+
+ Used for subtype="stop_hook_summary" system messages.
+ """
+
+ has_output: bool
+ hook_errors: List[str] # Error messages from hooks
+ hook_infos: List[HookInfo] # Info about each hook executed
+
+
+# =============================================================================
+# User Message Content Models
+# =============================================================================
+# Structured content models for user message variants.
+# These classify user text based on flags and tag patterns.
+
+
+@dataclass
+class SlashCommandContent(MessageContent):
+ """Content for slash command invocations (e.g., /context, /model).
+
+ These are user messages containing command-name, command-args, and
+ command-contents tags parsed from the text.
+ """
+
+ command_name: str
+ command_args: str
+ command_contents: str
+
+
+@dataclass
+class CommandOutputContent(MessageContent):
+ """Content for local command output (e.g., output from /context).
+
+ These are user messages containing local-command-stdout tags.
+ """
+
+ stdout: str
+ is_markdown: bool # True if content appears to be markdown
+
+
+@dataclass
+class BashInputContent(MessageContent):
+ """Content for inline bash commands in user messages.
+
+ These are user messages containing bash-input tags.
+ """
+
+ command: str
+
+
+@dataclass
+class BashOutputContent(MessageContent):
+ """Content for bash command output.
+
+ These are user messages containing bash-stdout and/or bash-stderr tags.
+ """
+
+ stdout: Optional[str] = None # Raw stdout content (may contain ANSI codes)
+ stderr: Optional[str] = None # Raw stderr content (may contain ANSI codes)
+
+
+@dataclass
+class ToolResultContentModel(MessageContent):
+ """Content model for tool results with rendering context.
+
+ Wraps ToolResultContent with additional context needed for rendering,
+ such as the associated tool name and file path.
+ """
+
+ tool_use_id: str
+ content: Any # Union[str, List[Dict[str, Any]]]
+ is_error: bool = False
+ tool_name: Optional[str] = None # Name of the tool that produced this result
+ file_path: Optional[str] = None # File path for Read/Edit/Write tools
+
+
+@dataclass
+class CompactedSummaryContent(MessageContent):
+ """Content for compacted session summaries.
+
+ These are user messages that contain previous conversation context
+ in a compacted format when sessions run out of context.
+ Parsed by parse_compacted_summary() in parser.py, formatted by
+ format_compacted_summary_content() in html/user_formatters.py.
+ """
+
+ summary_text: str
+
+
+@dataclass
+class UserMemoryContent(MessageContent):
+ """Content for user memory input.
+
+ These are user messages containing user-memory-input tags.
+ Parsed by parse_user_memory() in parser.py, formatted by
+ format_user_memory_content() in html/user_formatters.py.
+ """
+
+ memory_text: str
+
+
+@dataclass
+class IdeOpenedFile:
+ """IDE notification for an opened file."""
+
+ content: str # Raw content from the tag
+
+
+@dataclass
+class IdeSelection:
+ """IDE notification for a code selection."""
+
+ content: str # Raw selection content
+
+
+@dataclass
+class IdeDiagnostic:
+ """IDE diagnostic notification.
+
+ Contains either parsed JSON diagnostics or raw content if parsing failed.
+ """
+
+ diagnostics: Optional[List[Dict[str, Any]]] = None # Parsed diagnostic objects
+ raw_content: Optional[str] = None # Fallback if JSON parsing failed
+
+
+@dataclass
+class IdeNotificationContent(MessageContent):
+ """Content for IDE notification tags.
+
+ These are user messages containing IDE notification tags like:
+ - : File open notifications
+ - : Code selection notifications
+ - : Diagnostic JSON arrays
+
+ Format-neutral: stores structured data, not HTML.
+ """
+
+ opened_files: List[IdeOpenedFile]
+ selections: List[IdeSelection]
+ diagnostics: List[IdeDiagnostic]
+ remaining_text: str # Text after notifications extracted
+
+
+@dataclass
+class UserTextContent(MessageContent):
+ """Content for plain user text with optional IDE notifications.
+
+ Wraps user text that may have been preprocessed to extract
+ IDE notifications, compacted summaries, or memory input markers.
+
+ TODO: Not currently instantiated - formatter exists but pipeline uses
+ separate IdeNotificationContent and plain text instead.
+ """
+
+ text: str
+ ide_notifications: Optional[IdeNotificationContent] = None
+ is_compacted: bool = False
+ is_memory_input: bool = False
+
+
+# =============================================================================
+# Assistant Message Content Models
+# =============================================================================
+# Structured content models for assistant message variants.
+# These classify assistant message parts for format-neutral rendering.
+
+
+@dataclass
+class AssistantTextContent(MessageContent):
+ """Content for assistant text messages.
+
+ These are the text portions of assistant messages that get
+ rendered as markdown with syntax highlighting.
+ """
+
+ text: str
+
+
+@dataclass
+class ThinkingContentModel(MessageContent):
+ """Content for assistant thinking/reasoning blocks.
+
+ These are the blocks that show the assistant's
+ internal reasoning process.
+
+ Note: This is distinct from ThinkingContent (the Pydantic model
+ for parsing JSONL). This dataclass is for rendering purposes.
+ """
+
+ thinking: str
+ signature: Optional[str] = None
+
+
+@dataclass
+class UnknownContent(MessageContent):
+ """Content for unknown/unrecognized content types.
+
+ Used as a fallback when encountering content types that don't have
+ specific handlers. Stores the type name for display purposes.
+ """
+
+ type_name: str # The name/description of the unknown type
+
+
+# =============================================================================
+# Tool Output Content Models
+# =============================================================================
+# Structured content models for tool results (symmetric with Tool Input Models).
+# These provide format-neutral representation of tool outputs that renderers
+# can format appropriately.
+
+
+@dataclass
+class ReadOutput(MessageContent):
+ """Parsed Read tool output.
+
+ Represents the result of reading a file with optional line range.
+ Symmetric with ReadInput for tool_use → tool_result pairing.
+ """
+
+ file_path: str
+ content: str # File content (may be truncated)
+ start_line: int # 1-based starting line number
+ num_lines: int # Number of lines in content
+ total_lines: int # Total lines in file
+ is_truncated: bool # Whether content was truncated
+ system_reminder: Optional[str] = None # Embedded system reminder text
+
+
+@dataclass
+class WriteOutput(MessageContent):
+ """Parsed Write tool output.
+
+ Symmetric with WriteInput for tool_use → tool_result pairing.
+
+ TODO: Not currently used - tool results handled as raw strings.
+ """
+
+ file_path: str
+ success: bool
+ message: str # Success or error message
+
+
+@dataclass
+class EditDiff:
+ """Single diff hunk for edit operations."""
+
+ old_text: str
+ new_text: str
+
+
+@dataclass
+class EditOutput(MessageContent):
+ """Parsed Edit tool output.
+
+ Contains diff information for file edits.
+ Symmetric with EditInput for tool_use → tool_result pairing.
+ """
+
+ file_path: str
+ success: bool
+ diffs: List[EditDiff] # Changes made
+ message: str # Result message or code snippet
+ start_line: int = 1 # Starting line number for code display
+
+
+@dataclass
+class BashOutput(MessageContent):
+ """Parsed Bash tool output.
+
+ Symmetric with BashInput for tool_use → tool_result pairing.
+
+ TODO: Not currently used - tool results handled as raw strings.
+ """
+
+ stdout: str
+ stderr: str
+ exit_code: Optional[int]
+ interrupted: bool
+ is_image: bool # True if output contains image data
+
+
+@dataclass
+class TaskOutput(MessageContent):
+ """Parsed Task (sub-agent) tool output.
+
+ Symmetric with TaskInput for tool_use → tool_result pairing.
+
+ TODO: Not currently used - tool results handled as raw strings.
+ """
+
+ agent_id: Optional[str]
+ result: str # Agent's response
+ is_background: bool
+
+
+@dataclass
+class GlobOutput(MessageContent):
+ """Parsed Glob tool output.
+
+ Symmetric with GlobInput for tool_use → tool_result pairing.
+
+ TODO: Not currently used - tool results handled as raw strings.
+ """
+
+ pattern: str
+ files: List[str] # Matching file paths
+ truncated: bool # Whether list was truncated
+
+
+@dataclass
+class GrepOutput(MessageContent):
+ """Parsed Grep tool output.
+
+ Symmetric with GrepInput for tool_use → tool_result pairing.
+
+ TODO: Not currently used - tool results handled as raw strings.
+ """
+
+ pattern: str
+ matches: List[str] # Matching lines/files
+ output_mode: str # "content", "files_with_matches", or "count"
+ truncated: bool
+
+
+# =============================================================================
+# Renderer Content Models
+# =============================================================================
+# Structured content models for renderer-specific elements.
+# These are used by the HTML renderer but represent format-neutral data.
+
+
+@dataclass
+class SessionHeaderContent(MessageContent):
+ """Content for session headers in transcript rendering.
+
+ Represents the header displayed at the start of each session
+ with session title and optional summary.
+ """
+
+ title: str
+ session_id: str
+ summary: Optional[str] = None
+
+
+@dataclass
+class DedupNoticeContent(MessageContent):
+ """Content for deduplication notices.
+
+ Displayed when content is deduplicated (e.g., sidechain assistant
+ text that duplicates the Task tool result).
+ """
+
+ notice_text: str
+
+
+# =============================================================================
+# Tool Input Models
+# =============================================================================
+# Typed models for tool inputs (Phase 11 of MESSAGE_REFACTORING.md)
+# These provide type safety and IDE autocompletion for tool parameters.
+
+
+class BashInput(BaseModel):
+ """Input parameters for the Bash tool."""
+
+ command: str
+ description: Optional[str] = None
+ timeout: Optional[int] = None
+ run_in_background: Optional[bool] = None
+ dangerouslyDisableSandbox: Optional[bool] = None
+
+
+class ReadInput(BaseModel):
+ """Input parameters for the Read tool."""
+
+ file_path: str
+ offset: Optional[int] = None
+ limit: Optional[int] = None
+
+
+class WriteInput(BaseModel):
+ """Input parameters for the Write tool."""
+
+ file_path: str
content: str
- status: Literal["pending", "in_progress", "completed"]
- priority: Literal["high", "medium", "low"]
+
+
+class EditInput(BaseModel):
+ """Input parameters for the Edit tool."""
+
+ file_path: str
+ old_string: str
+ new_string: str
+ replace_all: Optional[bool] = None
+
+
+class EditItem(BaseModel):
+ """Single edit item for MultiEdit tool."""
+
+ old_string: str
+ new_string: str
+
+
+class MultiEditInput(BaseModel):
+ """Input parameters for the MultiEdit tool."""
+
+ file_path: str
+ edits: List[EditItem]
+
+
+class GlobInput(BaseModel):
+ """Input parameters for the Glob tool."""
+
+ pattern: str
+ path: Optional[str] = None
+
+
+class GrepInput(BaseModel):
+ """Input parameters for the Grep tool.
+
+ Note: Extra fields like -A, -B, -C are allowed for flexibility.
+ """
+
+ pattern: str
+ path: Optional[str] = None
+ glob: Optional[str] = None
+ type: Optional[str] = None
+ output_mode: Optional[Literal["content", "files_with_matches", "count"]] = None
+ multiline: Optional[bool] = None
+ head_limit: Optional[int] = None
+ offset: Optional[int] = None
+
+ model_config = {"extra": "allow"} # Allow -A, -B, -C, -i, -n fields
+
+
+class TaskInput(BaseModel):
+ """Input parameters for the Task tool."""
+
+ prompt: str
+ subagent_type: str
+ description: str
+ model: Optional[Literal["sonnet", "opus", "haiku"]] = None
+ run_in_background: Optional[bool] = None
+ resume: Optional[str] = None
+
+
+class TodoWriteItem(BaseModel):
+ """Single todo item for TodoWrite tool (input format).
+
+ All fields have defaults for lenient parsing of legacy/malformed data.
+ """
+
+ content: str = ""
+ status: str = "pending" # Allow any string, not just Literal, for flexibility
+ activeForm: str = ""
+ id: Optional[str] = None
+ priority: Optional[str] = None # Allow any string for flexibility
+
+
+class TodoWriteInput(BaseModel):
+ """Input parameters for the TodoWrite tool."""
+
+ todos: List[TodoWriteItem]
+
+
+class AskUserQuestionOption(BaseModel):
+ """Option for an AskUserQuestion question.
+
+ All fields have defaults for lenient parsing.
+ """
+
+ label: str = ""
+ description: Optional[str] = None
+
+
+class AskUserQuestionItem(BaseModel):
+ """Single question in AskUserQuestion input.
+
+ All fields have defaults for lenient parsing.
+ """
+
+ question: str = ""
+ header: Optional[str] = None
+ options: List[AskUserQuestionOption] = []
+ multiSelect: bool = False
+
+
+class AskUserQuestionInput(BaseModel):
+ """Input parameters for the AskUserQuestion tool.
+
+ Supports both modern format (questions list) and legacy format (single question).
+ """
+
+ questions: List[AskUserQuestionItem] = []
+ question: Optional[str] = None # Legacy single question format
+
+
+class ExitPlanModeInput(BaseModel):
+ """Input parameters for the ExitPlanMode tool."""
+
+ plan: str = ""
+ launchSwarm: Optional[bool] = None
+ teammateCount: Optional[int] = None
+
+
+# Union of all typed tool inputs
+ToolInput = Union[
+ BashInput,
+ ReadInput,
+ WriteInput,
+ EditInput,
+ MultiEditInput,
+ GlobInput,
+ GrepInput,
+ TaskInput,
+ TodoWriteInput,
+ AskUserQuestionInput,
+ ExitPlanModeInput,
+ Dict[str, Any], # Fallback for unknown tools
+]
class UsageInfo(BaseModel):
@@ -62,11 +684,28 @@ class TextContent(BaseModel):
text: str
-class ToolUseContent(BaseModel):
+class ToolUseContent(BaseModel, MessageContent):
type: Literal["tool_use"]
id: str
name: str
input: Dict[str, Any]
+ _parsed_input: Optional["ToolInput"] = None # Cached parsed input
+
+ @property
+ def parsed_input(self) -> "ToolInput":
+ """Get typed input model if available, otherwise return raw dict.
+
+ Lazily parses the input dict into a typed model.
+ Uses strict validation first, then lenient parsing if available.
+ Result is cached for subsequent accesses.
+ """
+ if self._parsed_input is None:
+ from .parser import parse_tool_input
+
+ object.__setattr__(
+ self, "_parsed_input", parse_tool_input(self.name, self.input)
+ )
+ return self._parsed_input # type: ignore[return-value]
class ToolResultContent(BaseModel):
@@ -89,7 +728,7 @@ class ImageSource(BaseModel):
data: str
-class ImageContent(BaseModel):
+class ImageContent(BaseModel, MessageContent):
type: Literal["image"]
source: ImageSource
@@ -108,6 +747,7 @@ class ImageContent(BaseModel):
class UserMessage(BaseModel):
role: Literal["user"]
content: Union[str, List[ContentItem]]
+ usage: Optional["UsageInfo"] = None # For type compatibility with AssistantMessage
class AssistantMessage(BaseModel):
@@ -127,6 +767,8 @@ def from_anthropic_message(
cls, anthropic_msg: AnthropicMessage
) -> "AssistantMessage":
"""Create AssistantMessage from official Anthropic Message."""
+ from .parser import normalize_usage_info
+
# Convert Anthropic Message to our format, preserving official types where possible
return cls(
id=anthropic_msg.id,
@@ -142,48 +784,13 @@ def from_anthropic_message(
)
-class FileInfo(BaseModel):
- filePath: str
- content: str
- numLines: int
- startLine: int
- totalLines: int
-
-
-class FileReadResult(BaseModel):
- type: Literal["text"]
- file: FileInfo
-
-
-class CommandResult(BaseModel):
- stdout: str
- stderr: str
- interrupted: bool
- isImage: bool
-
-
-class TodoResult(BaseModel):
- oldTodos: List[TodoItem]
- newTodos: List[TodoItem]
-
-
-class EditResult(BaseModel):
- oldString: Optional[str] = None
- newString: Optional[str] = None
- replaceAll: Optional[bool] = None
- originalFile: Optional[str] = None
- structuredPatch: Optional[Any] = None
- userModified: Optional[bool] = None
-
-
+# Tool result type - flexible to accept various result formats from JSONL
+# The specific parsing/formatting happens in tool_formatters.py using
+# ReadOutput, EditOutput, etc. (see Tool Output Content Models section)
ToolUseResult = Union[
str,
- List[TodoItem],
- FileReadResult,
- CommandResult,
- TodoResult,
- EditResult,
- List[ContentItem],
+ List[Any], # Covers List[TodoWriteItem], List[ContentItem], etc.
+ Dict[str, Any], # Covers structured results
]
@@ -218,6 +825,7 @@ class SummaryTranscriptEntry(BaseModel):
summary: str
leafUuid: str
cwd: Optional[str] = None
+ sessionId: None = None # Summaries don't have a sessionId
class SystemTranscriptEntry(BaseTranscriptEntry):
@@ -261,179 +869,3 @@ class QueueOperationTranscriptEntry(BaseModel):
SystemTranscriptEntry,
QueueOperationTranscriptEntry,
]
-
-
-def normalize_usage_info(usage_data: Any) -> Optional[UsageInfo]:
- """Normalize usage data to be compatible with both custom and Anthropic formats."""
- if usage_data is None:
- return None
-
- # If it's already a UsageInfo instance, return as-is
- if isinstance(usage_data, UsageInfo):
- return usage_data
-
- # If it's an Anthropic Usage instance, convert using our method
- if isinstance(usage_data, AnthropicUsage):
- return UsageInfo.from_anthropic_usage(usage_data)
-
- # If it has the shape of an Anthropic Usage, try to construct it first
- if hasattr(usage_data, "input_tokens") and hasattr(usage_data, "output_tokens"):
- try:
- # Try to create an Anthropic Usage first
- anthropic_usage = AnthropicUsage.model_validate(usage_data)
- return UsageInfo.from_anthropic_usage(anthropic_usage)
- except Exception:
- # Fall back to direct conversion
- return UsageInfo(
- input_tokens=getattr(usage_data, "input_tokens", None),
- cache_creation_input_tokens=getattr(
- usage_data, "cache_creation_input_tokens", None
- ),
- cache_read_input_tokens=getattr(
- usage_data, "cache_read_input_tokens", None
- ),
- output_tokens=getattr(usage_data, "output_tokens", None),
- service_tier=getattr(usage_data, "service_tier", None),
- server_tool_use=getattr(usage_data, "server_tool_use", None),
- )
-
- # If it's a dict, validate and convert to our format
- if isinstance(usage_data, dict):
- return UsageInfo.model_validate(usage_data)
-
- return None
-
-
-def parse_content_item(item_data: Dict[str, Any]) -> ContentItem:
- """Parse a content item using enhanced approach with Anthropic types."""
- try:
- content_type = item_data.get("type", "")
-
- # Try official Anthropic types first for better future compatibility
- if content_type == "text":
- try:
- from anthropic.types.text_block import TextBlock
-
- return TextBlock.model_validate(item_data)
- except Exception:
- return TextContent.model_validate(item_data)
- elif content_type == "tool_use":
- try:
- from anthropic.types.tool_use_block import ToolUseBlock
-
- return ToolUseBlock.model_validate(item_data)
- except Exception:
- return ToolUseContent.model_validate(item_data)
- elif content_type == "thinking":
- try:
- from anthropic.types.thinking_block import ThinkingBlock
-
- return ThinkingBlock.model_validate(item_data)
- except Exception:
- return ThinkingContent.model_validate(item_data)
- elif content_type == "tool_result":
- return ToolResultContent.model_validate(item_data)
- elif content_type == "image":
- return ImageContent.model_validate(item_data)
- else:
- # Fallback to text content for unknown types
- return TextContent(type="text", text=str(item_data))
- except Exception:
- return TextContent(type="text", text=str(item_data))
-
-
-def parse_message_content(content_data: Any) -> Union[str, List[ContentItem]]:
- """Parse message content, handling both string and list formats."""
- if isinstance(content_data, str):
- return content_data
- elif isinstance(content_data, list):
- content_list = cast(List[Dict[str, Any]], content_data)
- return [parse_content_item(item) for item in content_list]
- else:
- return str(content_data)
-
-
-def parse_transcript_entry(data: Dict[str, Any]) -> TranscriptEntry:
- """
- Parse a JSON dictionary into the appropriate TranscriptEntry type.
-
- Enhanced to optionally use official Anthropic types for assistant messages.
-
- Args:
- data: Dictionary parsed from JSON
-
- Returns:
- The appropriate TranscriptEntry subclass
-
- Raises:
- ValueError: If the data doesn't match any known transcript entry type
- """
- entry_type = data.get("type")
-
- if entry_type == "user":
- # Parse message content if present
- data_copy = data.copy()
- if "message" in data_copy and "content" in data_copy["message"]:
- data_copy["message"] = data_copy["message"].copy()
- data_copy["message"]["content"] = parse_message_content(
- data_copy["message"]["content"]
- )
- # Parse toolUseResult if present and it's a list of content items
- if "toolUseResult" in data_copy and isinstance(
- data_copy["toolUseResult"], list
- ):
- # Check if it's a list of content items (MCP tool results)
- tool_use_result = cast(List[Any], data_copy["toolUseResult"])
- if (
- tool_use_result
- and isinstance(tool_use_result[0], dict)
- and "type" in tool_use_result[0]
- ):
- data_copy["toolUseResult"] = [
- parse_content_item(cast(Dict[str, Any], item))
- for item in tool_use_result
- if isinstance(item, dict)
- ]
- return UserTranscriptEntry.model_validate(data_copy)
-
- elif entry_type == "assistant":
- # Enhanced assistant message parsing with optional Anthropic types
- data_copy = data.copy()
-
- # Validate compatibility with official Anthropic Message type
- if "message" in data_copy:
- try:
- message_data = data_copy["message"]
- AnthropicMessage.model_validate(message_data)
- # Successfully validated - our data is compatible with official Anthropic types
- except Exception:
- # Validation failed - continue with standard parsing
- pass
-
- # Standard parsing path (works for all cases)
- if "message" in data_copy and "content" in data_copy["message"]:
- message_copy = data_copy["message"].copy()
- message_copy["content"] = parse_message_content(message_copy["content"])
-
- # Normalize usage data to support both Anthropic and custom formats
- if "usage" in message_copy:
- message_copy["usage"] = normalize_usage_info(message_copy["usage"])
-
- data_copy["message"] = message_copy
- return AssistantTranscriptEntry.model_validate(data_copy)
-
- elif entry_type == "summary":
- return SummaryTranscriptEntry.model_validate(data)
-
- elif entry_type == "system":
- return SystemTranscriptEntry.model_validate(data)
-
- elif entry_type == "queue-operation":
- # Parse content if present (in enqueue and remove operations)
- data_copy = data.copy()
- if "content" in data_copy and isinstance(data_copy["content"], list):
- data_copy["content"] = parse_message_content(data_copy["content"])
- return QueueOperationTranscriptEntry.model_validate(data_copy)
-
- else:
- raise ValueError(f"Unknown transcript entry type: {entry_type}")
diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py
index a34ca5af..23266d3d 100644
--- a/claude_code_log/parser.py
+++ b/claude_code_log/parser.py
@@ -2,48 +2,81 @@
"""Parse and extract data from Claude transcript JSONL files."""
import json
-from pathlib import Path
import re
-from typing import Any, List, Optional, Union, TYPE_CHECKING
+from typing import Any, Callable, Dict, List, Optional, Union, cast, TypeGuard
from datetime import datetime
-import dateparser
+
+from anthropic.types import Message as AnthropicMessage
+from anthropic.types import Usage as AnthropicUsage
+from anthropic.types.text_block import TextBlock
+from anthropic.types.thinking_block import ThinkingBlock
+from pydantic import BaseModel
from .models import (
- TranscriptEntry,
- UserTranscriptEntry,
- SummaryTranscriptEntry,
- parse_transcript_entry,
+ # Content types
ContentItem,
TextContent,
ThinkingContent,
+ ToolUseContent,
+ ToolResultContent,
+ ImageContent,
+ # User message content models
+ SlashCommandContent,
+ CommandOutputContent,
+ BashInputContent,
+ BashOutputContent,
+ CompactedSummaryContent,
+ UserMemoryContent,
+ UserTextContent,
+ IdeNotificationContent,
+ IdeOpenedFile,
+ IdeSelection,
+ IdeDiagnostic,
+ # Assistant message content models
+ BashInput,
+ ReadInput,
+ WriteInput,
+ EditInput,
+ EditItem,
+ MultiEditInput,
+ GlobInput,
+ GrepInput,
+ TaskInput,
+ TodoWriteInput,
+ TodoWriteItem,
+ AskUserQuestionInput,
+ AskUserQuestionItem,
+ AskUserQuestionOption,
+ ExitPlanModeInput,
+ ToolInput,
+ # Usage and transcript entry types
+ UsageInfo,
+ MessageType,
+ TranscriptEntry,
+ UserTranscriptEntry,
+ AssistantTranscriptEntry,
+ SummaryTranscriptEntry,
+ SystemTranscriptEntry,
+ QueueOperationTranscriptEntry,
)
-if TYPE_CHECKING:
- from .cache import CacheManager
-
def extract_text_content(content: Union[str, List[ContentItem], None]) -> str:
- """Extract text content from Claude message content structure (supports both custom and Anthropic types)."""
+ """Extract text content from Claude message content structure.
+
+ Supports both custom models (TextContent, ThinkingContent) and official
+ Anthropic SDK types (TextBlock, ThinkingBlock).
+ """
if content is None:
return ""
if isinstance(content, list):
text_parts: List[str] = []
for item in content:
- # Handle both custom TextContent and official Anthropic TextBlock
- if isinstance(item, TextContent):
+ # Handle text content (custom TextContent or Anthropic TextBlock)
+ if isinstance(item, (TextContent, TextBlock)):
text_parts.append(item.text)
- elif (
- hasattr(item, "type")
- and hasattr(item, "text")
- and getattr(item, "type") == "text"
- ):
- # Official Anthropic TextBlock
- text_parts.append(getattr(item, "text"))
- elif isinstance(item, ThinkingContent):
- # Skip thinking content in main text extraction
- continue
- elif hasattr(item, "type") and getattr(item, "type") == "thinking":
- # Skip official Anthropic thinking content too
+ # Skip thinking content (custom ThinkingContent or Anthropic ThinkingBlock)
+ elif isinstance(item, (ThinkingContent, ThinkingBlock)):
continue
return "\n".join(text_parts)
else:
@@ -58,258 +91,866 @@ def parse_timestamp(timestamp_str: str) -> Optional[datetime]:
return None
-def filter_messages_by_date(
- messages: List[TranscriptEntry], from_date: Optional[str], to_date: Optional[str]
-) -> List[TranscriptEntry]:
- """Filter messages based on date range."""
- if not from_date and not to_date:
- return messages
-
- # Parse the date strings using dateparser
- from_dt = None
- to_dt = None
-
- if from_date:
- from_dt = dateparser.parse(from_date)
- if not from_dt:
- raise ValueError(f"Could not parse from-date: {from_date}")
- # If parsing relative dates like "today", start from beginning of day
- if from_date in ["today", "yesterday"] or "days ago" in from_date:
- from_dt = from_dt.replace(hour=0, minute=0, second=0, microsecond=0)
-
- if to_date:
- to_dt = dateparser.parse(to_date)
- if not to_dt:
- raise ValueError(f"Could not parse to-date: {to_date}")
- # If parsing relative dates like "today", end at end of day
- if to_date in ["today", "yesterday"] or "days ago" in to_date:
- to_dt = to_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
-
- filtered_messages: List[TranscriptEntry] = []
- for message in messages:
- # Handle SummaryTranscriptEntry which doesn't have timestamp
- if isinstance(message, SummaryTranscriptEntry):
- filtered_messages.append(message)
- continue
+# =============================================================================
+# User Message Content Parsing
+# =============================================================================
+
+
+def parse_slash_command(text: str) -> Optional[SlashCommandContent]:
+ """Parse slash command tags from text.
+
+ Args:
+ text: Raw text that may contain command-name, command-args, command-contents tags
+
+ Returns:
+ SlashCommandContent if tags found, None otherwise
+ """
+ command_name_match = re.search(r"([^<]+) ", text)
+ if not command_name_match:
+ return None
+
+ command_name = command_name_match.group(1).strip()
+
+ command_args_match = re.search(r"([^<]*) ", text)
+ command_args = command_args_match.group(1).strip() if command_args_match else ""
+
+ # Parse command contents, handling JSON format
+ command_contents_match = re.search(
+ r"(.+?) ", text, re.DOTALL
+ )
+ command_contents = ""
+ if command_contents_match:
+ contents_text = command_contents_match.group(1).strip()
+ # Try to parse as JSON and extract the text field
+ try:
+ contents_json: Any = json.loads(contents_text)
+ if isinstance(contents_json, dict) and "text" in contents_json:
+ text_dict = cast(Dict[str, Any], contents_json)
+ text_value = text_dict["text"]
+ command_contents = str(text_value)
+ else:
+ command_contents = contents_text
+ except json.JSONDecodeError:
+ command_contents = contents_text
+
+ return SlashCommandContent(
+ command_name=command_name,
+ command_args=command_args,
+ command_contents=command_contents,
+ )
+
+
+def parse_command_output(text: str) -> Optional[CommandOutputContent]:
+ """Parse command output tags from text.
+
+ Args:
+ text: Raw text that may contain local-command-stdout tags
- timestamp_str = message.timestamp
- if not timestamp_str:
- continue
+ Returns:
+ CommandOutputContent if tags found, None otherwise
+ """
+ stdout_match = re.search(
+ r"(.*?) ",
+ text,
+ re.DOTALL,
+ )
+ if not stdout_match:
+ return None
+
+ stdout_content = stdout_match.group(1).strip()
+ # Check if content looks like markdown (starts with markdown headers)
+ is_markdown = bool(re.match(r"^#+\s+", stdout_content, re.MULTILINE))
- message_dt = parse_timestamp(timestamp_str)
- if not message_dt:
- continue
+ return CommandOutputContent(stdout=stdout_content, is_markdown=is_markdown)
- # Convert to naive datetime for comparison (dateparser returns naive datetimes)
- if message_dt.tzinfo:
- message_dt = message_dt.replace(tzinfo=None)
- # Check if message falls within date range
- if from_dt and message_dt < from_dt:
- continue
- if to_dt and message_dt > to_dt:
- continue
+def parse_bash_input(text: str) -> Optional[BashInputContent]:
+ """Parse bash input tags from text.
- filtered_messages.append(message)
+ Args:
+ text: Raw text that may contain bash-input tags
+
+ Returns:
+ BashInputContent if tags found, None otherwise
+ """
+ bash_match = re.search(r"(.*?) ", text, re.DOTALL)
+ if not bash_match:
+ return None
- return filtered_messages
+ return BashInputContent(command=bash_match.group(1).strip())
-def load_transcript(
- jsonl_path: Path,
- cache_manager: Optional["CacheManager"] = None,
- from_date: Optional[str] = None,
- to_date: Optional[str] = None,
- silent: bool = False,
- _loaded_files: Optional[set[Path]] = None,
-) -> List[TranscriptEntry]:
- """Load and parse JSONL transcript file, using cache if available.
+def parse_bash_output(text: str) -> Optional[BashOutputContent]:
+ """Parse bash output tags from text.
Args:
- _loaded_files: Internal parameter to track loaded files and prevent infinite recursion.
+ text: Raw text that may contain bash-stdout/bash-stderr tags
+
+ Returns:
+ BashOutputContent if tags found, None otherwise
"""
- # Initialize loaded files set on first call
- if _loaded_files is None:
- _loaded_files = set()
-
- # Prevent infinite recursion by checking if this file is already being loaded
- if jsonl_path in _loaded_files:
- return []
-
- _loaded_files.add(jsonl_path)
- # Try to load from cache first
- if cache_manager is not None:
- # Use filtered loading if date parameters are provided
- if from_date or to_date:
- cached_entries = cache_manager.load_cached_entries_filtered(
- jsonl_path, from_date, to_date
- )
- else:
- cached_entries = cache_manager.load_cached_entries(jsonl_path)
-
- if cached_entries is not None:
- if not silent:
- print(f"Loading {jsonl_path} from cache...")
- return cached_entries
-
- # Parse from source file
- messages: List[TranscriptEntry] = []
- agent_ids: set[str] = set() # Collect agentId references while parsing
-
- with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
- if not silent:
- print(f"Processing {jsonl_path}...")
- for line_no, line in enumerate(f, 1): # Start counting from 1
- line = line.strip()
- if line:
- try:
- entry_dict: dict[str, Any] | str = json.loads(line)
- if not isinstance(entry_dict, dict):
- print(
- f"Line {line_no} of {jsonl_path} is not a JSON object: {line}"
- )
- continue
-
- # Check for agentId BEFORE Pydantic parsing
- # agentId can be at top level OR nested in toolUseResult
- # For UserTranscriptEntry, we need to copy it to top level so Pydantic preserves it
- if "agentId" in entry_dict:
- agent_id = entry_dict.get("agentId")
- if agent_id:
- agent_ids.add(agent_id)
- elif "toolUseResult" in entry_dict:
- tool_use_result = entry_dict.get("toolUseResult")
- if (
- isinstance(tool_use_result, dict)
- and "agentId" in tool_use_result
- ):
- agent_id_value = tool_use_result.get("agentId") # type: ignore[reportUnknownVariableType, reportUnknownMemberType]
- if isinstance(agent_id_value, str):
- agent_ids.add(agent_id_value)
- # Copy agentId to top level for Pydantic to preserve
- entry_dict["agentId"] = agent_id_value
-
- entry_type: str | None = entry_dict.get("type")
-
- if entry_type in [
- "user",
- "assistant",
- "summary",
- "system",
- "queue-operation",
- ]:
- # Parse using Pydantic models
- entry = parse_transcript_entry(entry_dict)
- messages.append(entry)
- elif (
- entry_type
- in [
- "file-history-snapshot", # Internal Claude Code file backup metadata
- ]
- ):
- # Silently skip internal message types we don't render
- pass
- else:
- print(
- f"Line {line_no} of {jsonl_path} is not a recognised message type: {line}"
- )
- except json.JSONDecodeError as e:
- print(
- f"Line {line_no} of {jsonl_path} | JSON decode error: {str(e)}"
- )
- except ValueError as e:
- # Extract a more descriptive error message
- error_msg = str(e)
- if "validation error" in error_msg.lower():
- err_no_url = re.sub(
- r" For further information visit https://errors.pydantic(.*)\n?",
- "",
- error_msg,
- )
- print(f"Line {line_no} of {jsonl_path} | {err_no_url}")
- else:
- print(
- f"Line {line_no} of {jsonl_path} | ValueError: {error_msg}"
- "\n{traceback.format_exc()}"
- )
- except Exception as e:
- print(
- f"Line {line_no} of {jsonl_path} | Unexpected error: {str(e)}"
- "\n{traceback.format_exc()}"
+ stdout_match = re.search(r"(.*?) ", text, re.DOTALL)
+ stderr_match = re.search(r"(.*?) ", text, re.DOTALL)
+
+ if not stdout_match and not stderr_match:
+ return None
+
+ stdout = stdout_match.group(1).strip() if stdout_match else None
+ stderr = stderr_match.group(1).strip() if stderr_match else None
+
+ # Convert empty strings to None for cleaner representation
+ if stdout == "":
+ stdout = None
+ if stderr == "":
+ stderr = None
+
+ return BashOutputContent(stdout=stdout, stderr=stderr)
+
+
+# Shared regex patterns for IDE notification tags
+IDE_OPENED_FILE_PATTERN = re.compile(
+ r"(.*?) ", re.DOTALL
+)
+IDE_SELECTION_PATTERN = re.compile(r"(.*?) ", re.DOTALL)
+IDE_DIAGNOSTICS_PATTERN = re.compile(
+ r"\s*(.*?) \s* ",
+ re.DOTALL,
+)
+
+
+def parse_ide_notifications(text: str) -> Optional[IdeNotificationContent]:
+ """Parse IDE notification tags from text.
+
+ Handles:
+ - : Simple file open notifications
+ - : Code selection notifications
+ - : JSON diagnostic arrays
+
+ Args:
+ text: Raw text that may contain IDE notification tags
+
+ Returns:
+ IdeNotificationContent if any tags found, None otherwise
+ """
+ opened_files: List[IdeOpenedFile] = []
+ selections: List[IdeSelection] = []
+ diagnostics: List[IdeDiagnostic] = []
+ remaining_text = text
+
+ # Pattern 1: content
+ for match in IDE_OPENED_FILE_PATTERN.finditer(remaining_text):
+ content = match.group(1).strip()
+ opened_files.append(IdeOpenedFile(content=content))
+
+ remaining_text = IDE_OPENED_FILE_PATTERN.sub("", remaining_text)
+
+ # Pattern 2: content
+ for match in IDE_SELECTION_PATTERN.finditer(remaining_text):
+ content = match.group(1).strip()
+ selections.append(IdeSelection(content=content))
+
+ remaining_text = IDE_SELECTION_PATTERN.sub("", remaining_text)
+
+ # Pattern 3: JSON
+ for match in IDE_DIAGNOSTICS_PATTERN.finditer(remaining_text):
+ json_content = match.group(1).strip()
+ try:
+ parsed_diagnostics: Any = json.loads(json_content)
+ if isinstance(parsed_diagnostics, list):
+ diagnostics.append(
+ IdeDiagnostic(
+ diagnostics=cast(List[Dict[str, Any]], parsed_diagnostics)
)
+ )
+ else:
+ # Not a list, store as raw content
+ diagnostics.append(IdeDiagnostic(raw_content=json_content))
+ except (json.JSONDecodeError, ValueError):
+ # JSON parsing failed, store raw content
+ diagnostics.append(IdeDiagnostic(raw_content=json_content))
- # Load agent files if any were referenced
- # Build a map of agentId -> agent messages
- agent_messages_map: dict[str, List[TranscriptEntry]] = {}
- if agent_ids:
- parent_dir = jsonl_path.parent
- for agent_id in agent_ids:
- agent_file = parent_dir / f"agent-{agent_id}.jsonl"
- # Skip if the agent file is the same as the current file (self-reference)
- if agent_file == jsonl_path:
- continue
- if agent_file.exists():
- if not silent:
- print(f"Loading agent file {agent_file}...")
- # Recursively load the agent file (it might reference other agents)
- agent_messages = load_transcript(
- agent_file,
- cache_manager,
- from_date,
- to_date,
- silent=True,
- _loaded_files=_loaded_files,
+ remaining_text = IDE_DIAGNOSTICS_PATTERN.sub("", remaining_text)
+
+ # Only return if we found any IDE tags
+ if not opened_files and not selections and not diagnostics:
+ return None
+
+ return IdeNotificationContent(
+ opened_files=opened_files,
+ selections=selections,
+ diagnostics=diagnostics,
+ remaining_text=remaining_text.strip(),
+ )
+
+
+# Pattern for compacted session summary detection
+COMPACTED_SUMMARY_PREFIX = "This session is being continued from a previous conversation that ran out of context"
+
+
+def parse_compacted_summary(
+ content_list: List[ContentItem],
+) -> Optional[CompactedSummaryContent]:
+ """Parse compacted session summary from content list.
+
+ Compacted summaries are generated when a session runs out of context and
+ needs to be continued. They contain a summary of the previous conversation.
+
+ If the first text item starts with the compacted summary prefix, all text
+ items are combined into a single CompactedSummaryContent.
+
+ Args:
+ content_list: List of ContentItem from user message
+
+ Returns:
+ CompactedSummaryContent if first text is a compacted summary, None otherwise
+ """
+ if not content_list or not hasattr(content_list[0], "text"):
+ return None
+
+ first_text = getattr(content_list[0], "text", "")
+ if not first_text.startswith(COMPACTED_SUMMARY_PREFIX):
+ return None
+
+ # Combine all text content for compacted summaries
+ # Use hasattr check to handle both TextContent models and SDK TextBlock objects
+ texts = cast(
+ list[str],
+ [item.text for item in content_list if hasattr(item, "text")], # type: ignore[union-attr]
+ )
+ all_text = "\n\n".join(texts)
+ return CompactedSummaryContent(summary_text=all_text)
+
+
+# Pattern for user memory input tag
+USER_MEMORY_PATTERN = re.compile(
+ r"(.*?) ", re.DOTALL
+)
+
+
+def parse_user_memory(text: str) -> Optional[UserMemoryContent]:
+ """Parse user memory input tag from text.
+
+ User memory input contains context that the user has provided from
+ their CLAUDE.md or other memory sources.
+
+ Args:
+ text: Raw text that may contain user memory input tag
+
+ Returns:
+ UserMemoryContent if tag found, None otherwise
+ """
+ match = USER_MEMORY_PATTERN.search(text)
+ if match:
+ memory_content = match.group(1).strip()
+ return UserMemoryContent(memory_text=memory_content)
+ return None
+
+
+# Type alias for content models returned by parse_user_message_content
+UserMessageContent = Union[CompactedSummaryContent, UserMemoryContent, UserTextContent]
+
+
+def parse_user_message_content(
+ content_list: List[ContentItem],
+) -> Optional[UserMessageContent]:
+ """Parse user message content into a structured content model.
+
+ Returns a content model for HtmlRenderer to format. The caller can use
+ isinstance() checks to determine the content type:
+ - CompactedSummaryContent: Session continuation summaries
+ - UserMemoryContent: User memory input from CLAUDE.md
+ - UserTextContent: Normal user text with optional IDE notifications
+
+ Args:
+ content_list: List of ContentItem from user message
+
+ Returns:
+ A content model, or None if content_list is empty or has no text.
+ """
+ # Check first text item
+ if not content_list or not hasattr(content_list[0], "text"):
+ return None
+
+ # Check for compacted session summary first (handles text combining internally)
+ compacted = parse_compacted_summary(content_list)
+ if compacted:
+ return compacted
+
+ first_text = getattr(content_list[0], "text", "")
+
+ # Check for user memory input
+ user_memory = parse_user_memory(first_text)
+ if user_memory:
+ return user_memory
+
+ # Parse IDE notifications from first text item
+ ide_content = parse_ide_notifications(first_text)
+
+ # Get remaining text after IDE notifications extracted
+ if ide_content:
+ remaining_text = ide_content.remaining_text
+ else:
+ remaining_text = first_text
+
+ # Combine remaining text with any other text items
+ # Use hasattr check to handle both TextContent models and SDK TextBlock objects
+ other_text: list[str] = [
+ item.text # type: ignore[union-attr]
+ for item in content_list[1:]
+ if hasattr(item, "text")
+ ]
+ all_text = remaining_text
+ if other_text:
+ all_text = "\n\n".join([remaining_text] + other_text)
+
+ # Return UserTextContent with optional IDE notifications
+ return UserTextContent(text=all_text, ide_notifications=ide_content)
+
+
+# =============================================================================
+# Message Type Detection
+# =============================================================================
+
+
+def is_system_message(text_content: str) -> bool:
+ """Check if a message is a system message that should be filtered out."""
+ system_message_patterns = [
+ "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.",
+ "[Request interrupted by user for tool use]",
+ "",
+ ]
+
+ return any(text_content.startswith(pattern) for pattern in system_message_patterns)
+
+
+def is_command_message(text_content: str) -> bool:
+ """Check if a message contains command information that should be displayed."""
+ return "" in text_content and "" in text_content
+
+
+def is_local_command_output(text_content: str) -> bool:
+ """Check if a message contains local command output."""
+ return "" in text_content
+
+
+def is_bash_input(text_content: str) -> bool:
+ """Check if a message contains bash input command."""
+ return "" in text_content and " " in text_content
+
+
+def is_bash_output(text_content: str) -> bool:
+ """Check if a message contains bash command output."""
+ return "" in text_content or "" in text_content
+
+
+def is_warmup_only_session(messages: List[TranscriptEntry], session_id: str) -> bool:
+ """Check if a session contains only warmup user messages.
+
+ A warmup session is one where ALL user messages are literally just "Warmup".
+ Sessions with no user messages return False (not considered warmup).
+
+ Args:
+ messages: List of all transcript entries
+ session_id: The session ID to check
+
+ Returns:
+ True if ALL user messages in the session are "Warmup", False otherwise
+ """
+ user_messages_in_session: List[str] = []
+
+ for message in messages:
+ if (
+ isinstance(message, UserTranscriptEntry)
+ and getattr(message, "sessionId", "") == session_id
+ and hasattr(message, "message")
+ ):
+ text_content = extract_text_content(message.message.content).strip()
+ user_messages_in_session.append(text_content)
+
+ # No user messages = not a warmup session
+ if not user_messages_in_session:
+ return False
+
+ # All user messages must be exactly "Warmup"
+ return all(msg == "Warmup" for msg in user_messages_in_session)
+
+
+# =============================================================================
+# Type Guards for TranscriptEntry
+# =============================================================================
+
+
+def is_user_entry(entry: TranscriptEntry) -> TypeGuard[UserTranscriptEntry]:
+ """Check if entry is a user transcript entry."""
+ return entry.type == MessageType.USER
+
+
+def is_assistant_entry(entry: TranscriptEntry) -> TypeGuard[AssistantTranscriptEntry]:
+ """Check if entry is an assistant transcript entry."""
+ return entry.type == MessageType.ASSISTANT
+
+
+# =============================================================================
+# Tool Input Parsing
+# =============================================================================
+
+TOOL_INPUT_MODELS: Dict[str, type[BaseModel]] = {
+ "Bash": BashInput,
+ "Read": ReadInput,
+ "Write": WriteInput,
+ "Edit": EditInput,
+ "MultiEdit": MultiEditInput,
+ "Glob": GlobInput,
+ "Grep": GrepInput,
+ "Task": TaskInput,
+ "TodoWrite": TodoWriteInput,
+ "AskUserQuestion": AskUserQuestionInput,
+ "ask_user_question": AskUserQuestionInput, # Legacy tool name
+ "ExitPlanMode": ExitPlanModeInput,
+}
+
+
+# -- Lenient Parsing Helpers --------------------------------------------------
+# These functions create typed models even when strict validation fails.
+# They use defaults for missing fields and skip invalid nested items.
+
+
+def _parse_todowrite_lenient(data: Dict[str, Any]) -> TodoWriteInput:
+ """Parse TodoWrite input leniently, handling malformed data."""
+ todos_raw = data.get("todos", [])
+ valid_todos: List[TodoWriteItem] = []
+ for item in todos_raw:
+ if isinstance(item, dict):
+ try:
+ valid_todos.append(TodoWriteItem.model_validate(item))
+ except Exception:
+ pass
+ elif isinstance(item, str):
+ valid_todos.append(TodoWriteItem(content=item))
+ return TodoWriteInput(todos=valid_todos)
+
+
+def _parse_bash_lenient(data: Dict[str, Any]) -> BashInput:
+ """Parse Bash input leniently."""
+ return BashInput(
+ command=data.get("command", ""),
+ description=data.get("description"),
+ timeout=data.get("timeout"),
+ run_in_background=data.get("run_in_background"),
+ )
+
+
+def _parse_write_lenient(data: Dict[str, Any]) -> WriteInput:
+ """Parse Write input leniently."""
+ return WriteInput(
+ file_path=data.get("file_path", ""),
+ content=data.get("content", ""),
+ )
+
+
+def _parse_edit_lenient(data: Dict[str, Any]) -> EditInput:
+ """Parse Edit input leniently."""
+ return EditInput(
+ file_path=data.get("file_path", ""),
+ old_string=data.get("old_string", ""),
+ new_string=data.get("new_string", ""),
+ replace_all=data.get("replace_all"),
+ )
+
+
+def _parse_multiedit_lenient(data: Dict[str, Any]) -> MultiEditInput:
+ """Parse Multiedit input leniently."""
+ edits_raw = data.get("edits", [])
+ valid_edits: List[EditItem] = []
+ for edit in edits_raw:
+ if isinstance(edit, dict):
+ try:
+ valid_edits.append(EditItem.model_validate(edit))
+ except Exception:
+ pass
+ return MultiEditInput(file_path=data.get("file_path", ""), edits=valid_edits)
+
+
+def _parse_task_lenient(data: Dict[str, Any]) -> TaskInput:
+ """Parse Task input leniently."""
+ return TaskInput(
+ prompt=data.get("prompt", ""),
+ subagent_type=data.get("subagent_type", ""),
+ description=data.get("description", ""),
+ model=data.get("model"),
+ run_in_background=data.get("run_in_background"),
+ resume=data.get("resume"),
+ )
+
+
+def _parse_read_lenient(data: Dict[str, Any]) -> ReadInput:
+ """Parse Read input leniently."""
+ return ReadInput(
+ file_path=data.get("file_path", ""),
+ offset=data.get("offset"),
+ limit=data.get("limit"),
+ )
+
+
+def _parse_askuserquestion_lenient(data: Dict[str, Any]) -> AskUserQuestionInput:
+ """Parse AskUserQuestion input leniently, handling malformed data."""
+ questions_raw = data.get("questions", [])
+ valid_questions: List[AskUserQuestionItem] = []
+ for q in questions_raw:
+ if isinstance(q, dict):
+ q_dict = cast(Dict[str, Any], q)
+ try:
+ # Parse options leniently
+ options_raw = q_dict.get("options", [])
+ valid_options: List[AskUserQuestionOption] = []
+ for opt in options_raw:
+ if isinstance(opt, dict):
+ try:
+ valid_options.append(
+ AskUserQuestionOption.model_validate(opt)
+ )
+ except Exception:
+ pass
+ valid_questions.append(
+ AskUserQuestionItem(
+ question=str(q_dict.get("question", "")),
+ header=q_dict.get("header"),
+ options=valid_options,
+ multiSelect=bool(q_dict.get("multiSelect", False)),
+ )
)
- agent_messages_map[agent_id] = agent_messages
-
- # Insert agent messages at their point of use
- if agent_messages_map:
- # Iterate through messages and insert agent messages after the message
- # that references them (via UserTranscriptEntry.agentId)
- result_messages: List[TranscriptEntry] = []
- for message in messages:
- result_messages.append(message)
-
- # Check if this is a UserTranscriptEntry with agentId
- if isinstance(message, UserTranscriptEntry) and message.agentId:
- agent_id = message.agentId
- if agent_id in agent_messages_map:
- # Insert agent messages right after this message
- result_messages.extend(agent_messages_map[agent_id])
-
- messages = result_messages
-
- # Save to cache if cache manager is available
- if cache_manager is not None:
- cache_manager.save_cached_entries(jsonl_path, messages)
-
- return messages
-
-
-def load_directory_transcripts(
- directory_path: Path,
- cache_manager: Optional["CacheManager"] = None,
- from_date: Optional[str] = None,
- to_date: Optional[str] = None,
- silent: bool = False,
-) -> List[TranscriptEntry]:
- """Load all JSONL transcript files from a directory and combine them."""
- all_messages: List[TranscriptEntry] = []
-
- # Find all .jsonl files
- jsonl_files = list(directory_path.glob("*.jsonl"))
-
- for jsonl_file in jsonl_files:
- messages = load_transcript(
- jsonl_file, cache_manager, from_date, to_date, silent
- )
- all_messages.extend(messages)
-
- # Sort all messages chronologically
- def get_timestamp(entry: TranscriptEntry) -> str:
- if hasattr(entry, "timestamp"):
- return entry.timestamp # type: ignore
- return ""
+ except Exception:
+ pass
+ return AskUserQuestionInput(
+ questions=valid_questions,
+ question=data.get("question"),
+ )
+
+
+def _parse_exitplanmode_lenient(data: Dict[str, Any]) -> ExitPlanModeInput:
+ """Parse ExitPlanMode input leniently."""
+ return ExitPlanModeInput(
+ plan=data.get("plan", ""),
+ launchSwarm=data.get("launchSwarm"),
+ teammateCount=data.get("teammateCount"),
+ )
+
+
+# Mapping of tool names to their lenient parsers
+TOOL_LENIENT_PARSERS: Dict[str, Any] = {
+ "Bash": _parse_bash_lenient,
+ "Write": _parse_write_lenient,
+ "Edit": _parse_edit_lenient,
+ "MultiEdit": _parse_multiedit_lenient,
+ "Task": _parse_task_lenient,
+ "TodoWrite": _parse_todowrite_lenient,
+ "Read": _parse_read_lenient,
+ "AskUserQuestion": _parse_askuserquestion_lenient,
+ "ask_user_question": _parse_askuserquestion_lenient, # Legacy tool name
+ "ExitPlanMode": _parse_exitplanmode_lenient,
+}
+
+
+def parse_tool_input(tool_name: str, input_data: Dict[str, Any]) -> ToolInput:
+ """Parse tool input dictionary into a typed model.
+
+ Uses strict validation first, then lenient parsing if available.
+
+ Args:
+ tool_name: The name of the tool (e.g., "Bash", "Read")
+ input_data: The raw input dictionary from the tool_use content
+
+ Returns:
+ A typed input model if available, otherwise the original dictionary
+ """
+ model_class = TOOL_INPUT_MODELS.get(tool_name)
+ if model_class is not None:
+ try:
+ return cast(ToolInput, model_class.model_validate(input_data))
+ except Exception:
+ # Try lenient parsing if available
+ lenient_parser = TOOL_LENIENT_PARSERS.get(tool_name)
+ if lenient_parser is not None:
+ return cast(ToolInput, lenient_parser(input_data))
+ return input_data
+ return input_data
+
+
+# =============================================================================
+# Usage Info Normalization
+# =============================================================================
+
+
+def normalize_usage_info(usage_data: Any) -> Optional[UsageInfo]:
+ """Normalize usage data to be compatible with both custom and Anthropic formats."""
+ if usage_data is None:
+ return None
+
+ # If it's already a UsageInfo instance, return as-is
+ if isinstance(usage_data, UsageInfo):
+ return usage_data
+
+ # If it's an Anthropic Usage instance, convert using our method
+ if isinstance(usage_data, AnthropicUsage):
+ return UsageInfo.from_anthropic_usage(usage_data)
+
+ # If it has the shape of an Anthropic Usage, try to construct it first
+ if hasattr(usage_data, "input_tokens") and hasattr(usage_data, "output_tokens"):
+ try:
+ # Try to create an Anthropic Usage first
+ anthropic_usage = AnthropicUsage.model_validate(usage_data)
+ return UsageInfo.from_anthropic_usage(anthropic_usage)
+ except Exception:
+ # Fall back to direct conversion
+ return UsageInfo(
+ input_tokens=getattr(usage_data, "input_tokens", None),
+ cache_creation_input_tokens=getattr(
+ usage_data, "cache_creation_input_tokens", None
+ ),
+ cache_read_input_tokens=getattr(
+ usage_data, "cache_read_input_tokens", None
+ ),
+ output_tokens=getattr(usage_data, "output_tokens", None),
+ service_tier=getattr(usage_data, "service_tier", None),
+ server_tool_use=getattr(usage_data, "server_tool_use", None),
+ )
+
+ # If it's a dict, validate and convert to our format
+ if isinstance(usage_data, dict):
+ return UsageInfo.model_validate(usage_data)
+
+ return None
+
+
+# =============================================================================
+# Content Item Parsing
+# =============================================================================
+# Functions to parse content items from JSONL data. Organized by entry type
+# to clarify which content types can appear in which context.
+
+
+def _parse_text_content(item_data: Dict[str, Any]) -> ContentItem:
+ """Parse text content, trying Anthropic types first.
+
+ Common to both user and assistant messages.
+ """
+ try:
+ return TextBlock.model_validate(item_data)
+ except Exception:
+ return TextContent.model_validate(item_data)
+
+
+def parse_user_content_item(item_data: Dict[str, Any]) -> ContentItem:
+ """Parse a content item from a UserTranscriptEntry.
+
+ User messages can contain:
+ - text: User-typed text
+ - tool_result: Results from tool execution
+ - image: User-attached images
+ """
+ try:
+ content_type = item_data.get("type", "")
+
+ if content_type == "text":
+ return _parse_text_content(item_data)
+ elif content_type == "tool_result":
+ return ToolResultContent.model_validate(item_data)
+ elif content_type == "image":
+ return ImageContent.model_validate(item_data)
+ else:
+ # Fallback to text content for unknown types
+ return TextContent(type="text", text=str(item_data))
+ except Exception:
+ return TextContent(type="text", text=str(item_data))
+
+
+def parse_assistant_content_item(item_data: Dict[str, Any]) -> ContentItem:
+ """Parse a content item from an AssistantTranscriptEntry.
- all_messages.sort(key=get_timestamp)
- return all_messages
+ Assistant messages can contain:
+ - text: Assistant's response text
+ - tool_use: Tool invocations
+ - thinking: Extended thinking blocks
+ """
+ try:
+ content_type = item_data.get("type", "")
+
+ if content_type == "text":
+ return _parse_text_content(item_data)
+ elif content_type == "tool_use":
+ try:
+ from anthropic.types.tool_use_block import ToolUseBlock
+
+ return ToolUseBlock.model_validate(item_data)
+ except Exception:
+ return ToolUseContent.model_validate(item_data)
+ elif content_type == "thinking":
+ try:
+ from anthropic.types.thinking_block import ThinkingBlock
+
+ return ThinkingBlock.model_validate(item_data)
+ except Exception:
+ return ThinkingContent.model_validate(item_data)
+ else:
+ # Fallback to text content for unknown types
+ return TextContent(type="text", text=str(item_data))
+ except Exception:
+ return TextContent(type="text", text=str(item_data))
+
+
+def parse_content_item(item_data: Dict[str, Any]) -> ContentItem:
+ """Parse a content item (generic fallback).
+
+ For cases where the entry type is unknown. Handles all content types.
+ Prefer parse_user_content_item or parse_assistant_content_item when
+ the entry type is known.
+ """
+ try:
+ content_type = item_data.get("type", "")
+
+ # User-specific content types
+ if content_type == "tool_result":
+ return ToolResultContent.model_validate(item_data)
+ elif content_type == "image":
+ return ImageContent.model_validate(item_data)
+
+ # Assistant-specific content types
+ elif content_type == "tool_use":
+ try:
+ from anthropic.types.tool_use_block import ToolUseBlock
+
+ return ToolUseBlock.model_validate(item_data)
+ except Exception:
+ return ToolUseContent.model_validate(item_data)
+ elif content_type == "thinking":
+ try:
+ from anthropic.types.thinking_block import ThinkingBlock
+
+ return ThinkingBlock.model_validate(item_data)
+ except Exception:
+ return ThinkingContent.model_validate(item_data)
+
+ # Common content types
+ elif content_type == "text":
+ return _parse_text_content(item_data)
+ else:
+ # Fallback to text content for unknown types
+ return TextContent(type="text", text=str(item_data))
+ except Exception:
+ return TextContent(type="text", text=str(item_data))
+
+
+def parse_message_content(
+ content_data: Any,
+ item_parser: Callable[[Dict[str, Any]], ContentItem] = parse_content_item,
+) -> Union[str, List[ContentItem]]:
+ """Parse message content, handling both string and list formats.
+
+ Args:
+ content_data: Raw content data (string or list of items)
+ item_parser: Function to parse individual content items. Defaults to
+ generic parse_content_item, but can be parse_user_content_item or
+ parse_assistant_content_item for type-specific parsing.
+ """
+ if isinstance(content_data, str):
+ return content_data
+ elif isinstance(content_data, list):
+ content_list = cast(List[Dict[str, Any]], content_data)
+ return [item_parser(item) for item in content_list]
+ else:
+ return str(content_data)
+
+
+# =============================================================================
+# Transcript Entry Parsing
+# =============================================================================
+
+
+def parse_transcript_entry(data: Dict[str, Any]) -> TranscriptEntry:
+ """
+ Parse a JSON dictionary into the appropriate TranscriptEntry type.
+
+ Enhanced to optionally use official Anthropic types for assistant messages.
+
+ Args:
+ data: Dictionary parsed from JSON
+
+ Returns:
+ The appropriate TranscriptEntry subclass
+
+ Raises:
+ ValueError: If the data doesn't match any known transcript entry type
+ """
+ entry_type = data.get("type")
+
+ if entry_type == "user":
+ # Parse message content if present, using user-specific parser
+ data_copy = data.copy()
+ if "message" in data_copy and "content" in data_copy["message"]:
+ data_copy["message"] = data_copy["message"].copy()
+ data_copy["message"]["content"] = parse_message_content(
+ data_copy["message"]["content"],
+ item_parser=parse_user_content_item,
+ )
+ # Parse toolUseResult if present and it's a list of content items
+ if "toolUseResult" in data_copy and isinstance(
+ data_copy["toolUseResult"], list
+ ):
+ # Check if it's a list of content items (MCP tool results)
+ tool_use_result = cast(List[Any], data_copy["toolUseResult"])
+ if (
+ tool_use_result
+ and isinstance(tool_use_result[0], dict)
+ and "type" in tool_use_result[0]
+ ):
+ data_copy["toolUseResult"] = [
+ parse_content_item(cast(Dict[str, Any], item))
+ for item in tool_use_result
+ if isinstance(item, dict)
+ ]
+ return UserTranscriptEntry.model_validate(data_copy)
+
+ elif entry_type == "assistant":
+ # Enhanced assistant message parsing with optional Anthropic types
+ data_copy = data.copy()
+
+ # Validate compatibility with official Anthropic Message type
+ if "message" in data_copy:
+ try:
+ message_data = data_copy["message"]
+ AnthropicMessage.model_validate(message_data)
+ # Successfully validated - our data is compatible with official Anthropic types
+ except Exception:
+ # Validation failed - continue with standard parsing
+ pass
+
+ # Standard parsing path using assistant-specific parser
+ if "message" in data_copy and "content" in data_copy["message"]:
+ message_copy = data_copy["message"].copy()
+ message_copy["content"] = parse_message_content(
+ message_copy["content"],
+ item_parser=parse_assistant_content_item,
+ )
+
+ # Normalize usage data to support both Anthropic and custom formats
+ if "usage" in message_copy:
+ message_copy["usage"] = normalize_usage_info(message_copy["usage"])
+
+ data_copy["message"] = message_copy
+ return AssistantTranscriptEntry.model_validate(data_copy)
+
+ elif entry_type == "summary":
+ return SummaryTranscriptEntry.model_validate(data)
+
+ elif entry_type == "system":
+ return SystemTranscriptEntry.model_validate(data)
+
+ elif entry_type == "queue-operation":
+ # Parse content if present (in enqueue and remove operations)
+ data_copy = data.copy()
+ if "content" in data_copy and isinstance(data_copy["content"], list):
+ data_copy["content"] = parse_message_content(data_copy["content"])
+ return QueueOperationTranscriptEntry.model_validate(data_copy)
+
+ else:
+ raise ValueError(f"Unknown transcript entry type: {entry_type}")
diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 6ea537a0..5b375380 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -1,1729 +1,88 @@
#!/usr/bin/env python3
"""Render Claude transcript data to HTML format."""
-import json
-import os
-import re
import time
+from dataclasses import dataclass, replace
from pathlib import Path
-from typing import List, Optional, Dict, Any, cast, TYPE_CHECKING
+from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .cache import CacheManager
+ from .models import MessageContent
from datetime import datetime
-import html
-import mistune
-from jinja2 import Environment, FileSystemLoader, select_autoescape
-from pygments import highlight # type: ignore[reportUnknownVariableType]
-from pygments.lexers import TextLexer # type: ignore[reportUnknownVariableType]
-from pygments.formatters import HtmlFormatter # type: ignore[reportUnknownVariableType]
-from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType]
from .models import (
+ MessageModifiers,
+ MessageType,
TranscriptEntry,
AssistantTranscriptEntry,
- UserTranscriptEntry,
SystemTranscriptEntry,
SummaryTranscriptEntry,
QueueOperationTranscriptEntry,
ContentItem,
TextContent,
ToolResultContent,
+ ToolResultContentModel,
ToolUseContent,
ThinkingContent,
+ ThinkingContentModel,
ImageContent,
+ # Structured content types
+ AssistantTextContent,
+ CompactedSummaryContent,
+ DedupNoticeContent,
+ HookInfo,
+ HookSummaryContent,
+ SessionHeaderContent,
+ SystemContent,
+ UnknownContent,
+ UserMemoryContent,
+ UserTextContent,
+)
+from .parser import (
+ extract_text_content,
+ is_assistant_entry,
+ is_bash_input,
+ is_bash_output,
+ is_command_message,
+ is_local_command_output,
+ is_user_entry,
+)
+from .utils import (
+ format_timestamp,
+ format_timestamp_range,
+ get_project_display_name,
+ should_skip_message,
+ should_use_as_session_starter,
+ create_session_preview,
+)
+from .renderer_timings import (
+ DEBUG_TIMING,
+ report_timing_statistics,
+ set_timing_var,
+ log_timing,
)
-from .parser import extract_text_content
-from .utils import (
- is_command_message,
- is_local_command_output,
- is_bash_input,
- is_bash_output,
- should_skip_message,
- should_use_as_session_starter,
- create_session_preview,
-)
-from .renderer_timings import (
- DEBUG_TIMING,
- report_timing_statistics,
- timing_stat,
- set_timing_var,
- log_timing,
-)
-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:
- """Get the display name for a project based on working directories.
-
- Args:
- project_dir_name: The Claude project directory name (e.g., "-Users-dain-workspace-claude-code-log")
- working_directories: List of working directories from cache data
-
- Returns:
- The project display name (e.g., "claude-code-log")
- """
- if working_directories:
- # Convert to Path objects with their original indices for tracking recency
- paths_with_indices = [(Path(wd), i) for i, wd in enumerate(working_directories)]
-
- # Sort by: 1) path depth (fewer parts = less nested), 2) recency (lower index = more recent)
- # This gives us the least nested path, with ties broken by recency
- best_path, _ = min(paths_with_indices, key=lambda p: (len(p[0].parts), p[1]))
- return best_path.name
- else:
- # Fall back to converting project directory name
- display_name = project_dir_name
- if display_name.startswith("-"):
- display_name = display_name[1:].replace("-", "/")
- return display_name
-
-
-def check_html_version(html_file_path: Path) -> Optional[str]:
- """Check the version of an existing HTML file from its comment.
-
- Returns:
- The version string if found, None if no version comment or file doesn't exist.
- """
- if not html_file_path.exists():
- return None
-
- try:
- with open(html_file_path, "r", encoding="utf-8") as f:
- # Read only the first few lines to find the version comment
- for _ in range(5): # Check first 5 lines
- line = f.readline()
- if not line:
- break
- # Look for comment like:
- if "'
- start = line.find("v") + 1
- end = line.find(" -->")
- if start > 0 and end > start:
- return line[start:end]
- except (IOError, UnicodeDecodeError):
- pass
-
- return None
-
-
-def is_html_outdated(html_file_path: Path) -> bool:
- """Check if an HTML file is outdated based on its version comment.
-
- Returns:
- True if the file should be regenerated (missing version, different version, or file doesn't exist).
- False if the file is current.
- """
- html_version = check_html_version(html_file_path)
- current_version = get_library_version()
-
- # If no version found or different version, it's outdated
- return html_version != current_version
-
-
-def format_timestamp(timestamp_str: str | None) -> str:
- """Format ISO timestamp for display, converting to UTC."""
- if timestamp_str is None:
- return ""
- try:
- dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
- # Convert to UTC if timezone-aware
- if dt.tzinfo is not None:
- utc_timetuple = dt.utctimetuple()
- dt = datetime(
- utc_timetuple.tm_year,
- utc_timetuple.tm_mon,
- utc_timetuple.tm_mday,
- utc_timetuple.tm_hour,
- utc_timetuple.tm_min,
- utc_timetuple.tm_sec,
- )
- return dt.strftime("%Y-%m-%d %H:%M:%S")
- except (ValueError, AttributeError):
- return timestamp_str
-
-
-def escape_html(text: str) -> str:
- """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_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,
- )
- # Track Pygments timing if enabled
- with timing_stat("_pygments_timings"):
- 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 with Pygments syntax highlighting."""
- # Track markdown rendering time if enabled
- with timing_stat("_markdown_timings"):
- # Configure mistune with GitHub-flavored markdown features
- renderer = mistune.create_markdown(
- plugins=[
- "strikethrough",
- "footnotes",
- "table",
- "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)
- )
- return str(renderer(text))
-
-
-def render_collapsible_code(
- preview_html: str,
- full_html: str,
- line_count: int,
- is_markdown: bool = False,
-) -> str:
- """Render a collapsible code/content block with preview.
-
- Creates a details element with a line count badge and preview content
- that expands to show the full content.
-
- Args:
- preview_html: HTML content to show in the collapsed summary
- full_html: HTML content to show when expanded
- line_count: Number of lines (shown in the badge)
- is_markdown: If True, adds 'markdown' class to preview and full content divs
-
- Returns:
- HTML string with collapsible details element
- """
- markdown_class = " markdown" if is_markdown else ""
- return f"""
-
- {line_count} lines
- {preview_html}
-
- {full_html}
- """
-
-
-def render_markdown_collapsible(
- raw_content: str,
- css_class: str,
- line_threshold: int = 20,
- preview_line_count: int = 5,
-) -> str:
- """Render markdown content, making it collapsible if it exceeds a line threshold.
-
- For long content, creates a collapsible details element with a preview.
- For short content, renders inline with the specified CSS class.
-
- Args:
- raw_content: The raw text content to render as markdown
- css_class: CSS class for the wrapper div (e.g., "task-prompt", "task-result")
- line_threshold: Number of lines above which content becomes collapsible (default 20)
- preview_line_count: Number of lines to show in the preview (default 5)
-
- Returns:
- HTML string with rendered markdown, optionally wrapped in collapsible details
- """
- rendered_html = render_markdown(raw_content)
-
- lines = raw_content.splitlines()
- if len(lines) <= line_threshold:
- # Short content, show inline
- return f'{rendered_html}'
-
- # Long content - make collapsible with rendered preview
- preview_lines = lines[:preview_line_count]
- preview_text = "\n".join(preview_lines)
- if len(lines) > preview_line_count:
- preview_text += "\n\n..."
- # Render truncated markdown (produces valid HTML with proper tag closure)
- preview_html = render_markdown(preview_text)
-
- collapsible = render_collapsible_code(
- preview_html, rendered_html, len(lines), is_markdown=True
- )
- return f'{collapsible}'
-
-
-def render_file_content_collapsible(
- code_content: str,
- file_path: str,
- css_class: str,
- linenostart: int = 1,
- line_threshold: int = 12,
- preview_line_count: int = 5,
- suffix_html: str = "",
-) -> str:
- """Render file content with syntax highlighting, collapsible if long.
-
- Highlights code using Pygments and wraps in a collapsible details element
- if the content exceeds the line threshold. Uses preview truncation from
- already-highlighted HTML to avoid double Pygments calls.
-
- Args:
- code_content: The raw code content to highlight
- file_path: File path for syntax detection (extension-based)
- css_class: CSS class for the wrapper div (e.g., 'write-tool-content')
- linenostart: Starting line number for Pygments (default 1)
- line_threshold: Number of lines above which content becomes collapsible
- preview_line_count: Number of lines to show in the preview
- suffix_html: Optional HTML to append after the code (inside wrapper div)
-
- Returns:
- HTML string with highlighted code, collapsible if >line_threshold lines
- """
- # Highlight code with Pygments (single call)
- highlighted_html = _highlight_code_with_pygments(
- code_content, file_path, linenostart=linenostart
- )
-
- html_parts = [f""]
-
- lines = code_content.split("\n")
- if len(lines) > line_threshold:
- # Extract preview from already-highlighted HTML (avoids double highlighting)
- preview_html = _truncate_highlighted_preview(
- highlighted_html, preview_line_count
- )
- html_parts.append(
- render_collapsible_code(preview_html, highlighted_html, len(lines))
- )
- else:
- # Show directly without collapsible
- html_parts.append(highlighted_html)
-
- if suffix_html:
- html_parts.append(suffix_html)
-
- html_parts.append("")
- return "".join(html_parts)
-
-
-def extract_command_info(text_content: str) -> tuple[str, str, str]:
- """Extract command info from system message with command tags."""
- import re
-
- # Extract command name
- command_name_match = re.search(
- r"([^<]+) ", text_content
- )
- command_name = (
- command_name_match.group(1).strip() if command_name_match else "system"
- )
-
- # Extract command args
- command_args_match = re.search(
- r"([^<]*) ", text_content
- )
- command_args = command_args_match.group(1).strip() if command_args_match else ""
-
- # Extract command contents
- command_contents_match = re.search(
- r"(.+?) ", text_content, re.DOTALL
- )
- command_contents: str = ""
- if command_contents_match:
- contents_text = command_contents_match.group(1).strip()
- # Try to parse as JSON and extract the text field
- try:
- contents_json: Any = json.loads(contents_text)
- if isinstance(contents_json, dict) and "text" in contents_json:
- text_dict = cast(Dict[str, Any], contents_json)
- text_value = text_dict["text"]
- command_contents = str(text_value)
- else:
- command_contents = contents_text
- except json.JSONDecodeError:
- command_contents = contents_text
-
- return command_name, command_args, command_contents
-
-
-def format_askuserquestion_content(tool_use: ToolUseContent) -> str:
- """Format AskUserQuestion tool use content with prominent question display.
-
- Handles multiple questions in a single tool use, each with optional header,
- options (with label and description), and multiSelect flag.
- """
- questions_data = tool_use.input.get("questions", [])
- # Also handle single question format for backwards compatibility
- if not questions_data:
- single_question = tool_use.input.get("question", "")
- if single_question:
- questions_data = [{"question": single_question}]
-
- if not questions_data:
- return render_params_table(tool_use.input)
-
- # Build HTML for all questions
- html_parts: List[str] = ['']
-
- for q_data in questions_data:
- try:
- question_text = escape_html(str(q_data.get("question", "")))
- header = q_data.get("header", "")
- options = q_data.get("options", [])
- multi_select = q_data.get("multiSelect", False)
-
- # Question container
- html_parts.append('')
-
- # Header (if present)
- if header:
- escaped_header = escape_html(str(header))
- html_parts.append(
- f'{escaped_header}'
- )
-
- # Question text with icon
- html_parts.append(f'❓ {question_text}')
-
- # Options (if present)
- if options:
- select_hint = "(select multiple)" if multi_select else "(select one)"
- html_parts.append(
- f'{select_hint}'
- )
- html_parts.append('')
- for opt in options:
- label = escape_html(str(opt.get("label", "")))
- desc = opt.get("description", "")
- if desc:
- desc_html = f' — {escape_html(str(desc))}'
- else:
- desc_html = ""
- html_parts.append(
- f'- {label}{desc_html}
'
- )
- html_parts.append("
")
-
- html_parts.append("") # Close question-block
- except (AttributeError, TypeError):
- # Fallback for unexpected format
- html_parts.append(
- f'❓ {escape_html(str(q_data))}'
- )
-
- html_parts.append("") # Close askuserquestion-content
- return "".join(html_parts)
-
-
-def format_askuserquestion_result(content: str) -> str:
- """Format AskUserQuestion tool result with styled question/answer pairs.
-
- Parses the result format:
- 'User has answered your questions: "Q1"="A1", "Q2"="A2". You can now continue...'
-
- Returns HTML with styled Q&A blocks matching the input styling.
- """
- import re
-
- # Check if this is a successful answer
- if not content.startswith("User has answered your question"):
- # Return as-is for errors or unexpected format
- return ""
-
- # Extract the Q&A portion between the colon and the final sentence
- # Pattern: 'User has answered your questions: "Q"="A", "Q"="A". You can now...'
- match = re.match(
- r"User has answered your questions?: (.+)\. You can now continue",
- content,
- re.DOTALL,
- )
- if not match:
- return ""
-
- qa_portion = match.group(1)
-
- # Parse "Question"="Answer" pairs
- # Pattern: "question text"="answer text"
- qa_pattern = re.compile(r'"([^"]+)"="([^"]+)"')
- pairs = qa_pattern.findall(qa_portion)
-
- if not pairs:
- return ""
-
- # Build styled HTML
- html_parts: List[str] = [
- ''
- ]
-
- for question, answer in pairs:
- escaped_q = escape_html(question)
- escaped_a = escape_html(answer)
- html_parts.append('')
- html_parts.append(f'❓ {escaped_q}')
- html_parts.append(f'✅ {escaped_a}')
- html_parts.append("")
-
- html_parts.append("")
- return "".join(html_parts)
-
-
-def format_exitplanmode_content(tool_use: ToolUseContent) -> str:
- """Format ExitPlanMode tool use content with collapsible plan markdown.
-
- Renders the plan markdown in a collapsible section, similar to Task tool results.
- """
- plan = tool_use.input.get("plan", "")
-
- if not plan:
- # No plan, show parameters table as fallback
- return render_params_table(tool_use.input)
-
- return render_markdown_collapsible(plan, "plan-content")
-
-
-def format_exitplanmode_result(content: str) -> str:
- """Format ExitPlanMode tool result, truncating the redundant plan echo.
-
- When a plan is approved, the result contains:
- 1. A confirmation message
- 2. Path to saved plan file
- 3. "## Approved Plan:" followed by full plan text (redundant)
-
- We truncate everything after "## Approved Plan:" to avoid duplication.
- For error results (plan not approved), we keep the full content.
- """
- # Check if this is a successful approval
- if "User has approved your plan" in content:
- # Truncate at "## Approved Plan:"
- marker = "## Approved Plan:"
- marker_pos = content.find(marker)
- if marker_pos > 0:
- # Keep everything before the marker, strip trailing whitespace
- return content[:marker_pos].rstrip()
-
- # For errors or other cases, return as-is
- return content
-
-
-def format_todowrite_content(tool_use: ToolUseContent) -> str:
- """Format TodoWrite tool use content as a todo list."""
- # Parse todos from input
- todos_data = tool_use.input.get("todos", [])
- if not todos_data:
- return """
-
- No todos found
-
- """
-
- # Status emojis
- status_emojis = {"pending": "⏳", "in_progress": "🔄", "completed": "✅"}
-
- # Build todo list HTML
- todo_items: List[str] = []
- for todo in todos_data:
- try:
- todo_id = escape_html(str(todo.get("id", "")))
- content = escape_html(str(todo.get("content", "")))
- status = str(todo.get("status", "pending")).lower()
- priority = str(todo.get("priority", "medium")).lower()
- status_emoji = status_emojis.get(status, "⏳")
-
- # CSS class for styling
- item_class = f"todo-item {status} {priority}"
-
- todo_items.append(f"""
-
- {status_emoji}
- {content}
- #{todo_id}
-
- """)
- except AttributeError:
- escaped_fallback = escape_html(str(todo))
- todo_items.append(f"""
-
- ⏳
- {escaped_fallback}
-
- """)
-
- todos_html = "".join(todo_items)
-
- return f"""
-
- {todos_html}
-
- """
-
-
-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
- """
- # PERFORMANCE FIX: Use Pygments' public API to build filename pattern mapping, avoiding filesystem I/O
- # get_lexer_for_filename performs I/O operations (file existence checks, reading bytes)
- # which causes severe slowdowns, especially on Windows with antivirus scanning
- # Solution: Build a reverse mapping from filename patterns to lexer aliases using get_all_lexers() (done once)
- import fnmatch
- from pygments.lexers import get_lexer_by_name, get_all_lexers # type: ignore[reportUnknownVariableType]
-
- # Build pattern->alias mapping on first call (cached as function attribute)
- # OPTIMIZATION: Create both direct extension lookup and full pattern cache
- if not hasattr(_highlight_code_with_pygments, "_pattern_cache"):
- pattern_cache: dict[str, str] = {}
- extension_cache: dict[str, str] = {} # Fast lookup for simple *.ext patterns
-
- # Use public API: get_all_lexers() returns (name, aliases, patterns, mimetypes) tuples
- for name, aliases, patterns, mimetypes in get_all_lexers(): # type: ignore[reportUnknownVariableType]
- if aliases and patterns:
- # Use first alias as the lexer name
- lexer_alias = aliases[0]
- # Map each filename pattern to this lexer alias
- for pattern in patterns:
- pattern_lower = pattern.lower()
- pattern_cache[pattern_lower] = lexer_alias
- # Extract simple extension patterns (*.ext) for fast lookup
- if (
- pattern_lower.startswith("*.")
- and "*" not in pattern_lower[2:]
- and "?" not in pattern_lower[2:]
- ):
- ext = pattern_lower[2:] # Remove "*."
- # Prefer first match for each extension
- if ext not in extension_cache:
- extension_cache[ext] = lexer_alias
-
- _highlight_code_with_pygments._pattern_cache = pattern_cache # type: ignore[attr-defined]
- _highlight_code_with_pygments._extension_cache = extension_cache # type: ignore[attr-defined]
-
- # Get basename for matching (patterns are like "*.py")
- basename = os.path.basename(file_path).lower()
-
- try:
- # Get caches
- pattern_cache = _highlight_code_with_pygments._pattern_cache # type: ignore[attr-defined]
- extension_cache = _highlight_code_with_pygments._extension_cache # type: ignore[attr-defined]
-
- # OPTIMIZATION: Try fast extension lookup first (O(1) dict lookup)
- lexer_alias = None
- if "." in basename:
- ext = basename.split(".")[-1] # Get last extension (handles .tar.gz, etc.)
- lexer_alias = extension_cache.get(ext)
-
- # Fall back to pattern matching only if extension lookup failed
- if lexer_alias is None:
- for pattern, lex_alias in pattern_cache.items():
- if fnmatch.fnmatch(basename, pattern):
- lexer_alias = lex_alias
- break
-
- # Get lexer or use TextLexer as fallback
- # Note: stripall=False preserves leading whitespace (important for code indentation)
- if lexer_alias:
- lexer = get_lexer_by_name(lexer_alias, stripall=False) # type: ignore[reportUnknownVariableType]
- else:
- lexer = TextLexer() # 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 with timing if enabled
- with timing_stat("_pygments_timings"):
- return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType]
-
-
-def _truncate_highlighted_preview(highlighted_html: str, max_lines: int) -> str:
- """Truncate Pygments highlighted HTML to first N lines.
-
- HtmlFormatter(linenos="table") produces a single with two s:
- LINE_NUMS
- CODE
-
- We truncate content within each tag to the first max_lines lines.
-
- Args:
- highlighted_html: Full Pygments-highlighted HTML
- max_lines: Maximum number of lines to include in preview
-
- Returns:
- Truncated HTML with same structure but fewer lines
- """
-
- def truncate_pre_content(match: re.Match[str]) -> str:
- """Truncate content inside a tag to max_lines."""
- prefix, content, suffix = match.groups()
- lines = content.split("\n")
- truncated = "\n".join(lines[:max_lines])
- return prefix + truncated + suffix
-
- # Truncate linenos content (line numbers separated by newlines)
- result = re.sub(
- r'()(.*?)(
)',
- truncate_pre_content,
- highlighted_html,
- flags=re.DOTALL,
- )
-
- # Truncate code content
- result = re.sub(
- r'(]*>)(.*?)(
)',
- truncate_pre_content,
- result,
- flags=re.DOTALL,
- )
-
- return result
-
-
-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", "")
-
- return render_file_content_collapsible(content, file_path, "write-tool-content")
-
-
-def format_bash_tool_content(tool_use: ToolUseContent) -> str:
- """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", "")
-
- escaped_command = escape_html(command)
-
- html_parts = [""]
-
- # Description is now shown in header, so we skip it here
-
- # 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, ensure_ascii=False) # 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"""
-
- {escaped_key}
- {value_html}
-
- """)
-
- html_parts.append("
")
- return "".join(html_parts)
-
-
-def _render_single_diff(old_string: str, new_string: str) -> str:
- """Render a single diff between old_string and new_string.
-
- Returns HTML for the diff view with intra-line highlighting.
- """
- import difflib
-
- # 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 = [""]
-
- 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 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)
-
-
-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_task_tool_content(tool_use: ToolUseContent) -> str:
- """Format Task tool content with markdown-rendered prompt.
-
- Task tool spawns sub-agents. We render the prompt as the main content.
- The sidechain user message (which would duplicate this prompt) is skipped.
-
- For long prompts (>20 lines), the content is made collapsible with a
- preview of the first few lines to keep the transcript vertically compact.
- """
- prompt = tool_use.input.get("prompt", "")
-
- if not prompt:
- # No prompt, show parameters table as fallback
- return render_params_table(tool_use.input)
-
- return render_markdown_collapsible(prompt, "task-prompt")
-
-
-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
-
- elif tool_name == "Task":
- # Return description if present
- description = params.get("description")
- if description:
- return description
-
- # 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
- if tool_use.name == "TodoWrite":
- return format_todowrite_content(tool_use)
-
- # Special handling for Bash
- if tool_use.name == "Bash":
- return format_bash_tool_content(tool_use)
-
- # Special handling for Edit
- 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)
-
- # Special handling for Task (agent spawning)
- if tool_use.name == "Task":
- return format_task_tool_content(tool_use)
-
- # Special handling for AskUserQuestion
- if tool_use.name == "AskUserQuestion":
- return format_askuserquestion_content(tool_use)
-
- # Special handling for ExitPlanMode
- if tool_use.name == "ExitPlanMode":
- return format_exitplanmode_content(tool_use)
-
- # Default: render as key/value table using shared renderer
- return render_params_table(tool_use.input)
-
-
-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", "Task")
- """
- # Handle both string and structured content
- if isinstance(tool_result.content, str):
- raw_content = tool_result.content
- has_images = False
- image_html_parts: List[str] = []
- else:
- # Content is a list of structured items, extract text and images
- content_parts: List[str] = []
- image_html_parts: List[str] = []
- for item in tool_result.content:
- item_type = item.get("type")
- if item_type == "text":
- text_value = item.get("text")
- if isinstance(text_value, str):
- content_parts.append(text_value)
- elif item_type == "image":
- # Handle image content within tool results
- source = cast(Dict[str, Any], item.get("source", {}))
- if source:
- media_type: str = str(source.get("media_type", "image/png"))
- data: str = str(source.get("data", ""))
- if data:
- data_url = f"data:{media_type};base64,{data}"
- image_html_parts.append(
- f'
'
- )
- 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
-
- # Build system reminder suffix if present
- suffix_html = ""
- if system_reminder:
- escaped_reminder = escape_html(system_reminder)
- suffix_html = (
- f"🤖 {escaped_reminder}"
- )
-
- return render_file_content_collapsible(
- code_content,
- file_path,
- "read-tool-result",
- linenostart=line_offset,
- suffix_html=suffix_html,
- )
-
- # 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
- return render_file_content_collapsible(
- parsed_code,
- file_path,
- "edit-tool-result",
- linenostart=line_offset,
- )
-
- # Special handling for Task tool: render result as markdown with Pygments (agent's final message)
- # Deduplication is now handled retroactively by replacing the sub-assistant content
- if tool_name == "Task" and not has_images:
- return render_markdown_collapsible(raw_content, "task-result")
-
- # Special handling for ExitPlanMode tool: truncate redundant plan echo on success
- if tool_name == "ExitPlanMode" and not has_images:
- processed_content = format_exitplanmode_result(raw_content)
- escaped_content = escape_html(processed_content)
- return f"{escaped_content}"
-
- # Special handling for AskUserQuestion tool: render Q&A pairs with styling
- if tool_name == "AskUserQuestion" and not has_images:
- styled_result = format_askuserquestion_result(raw_content)
- if styled_result:
- return styled_result
- # Fall through to default handling if parsing fails
-
- # 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):
- escaped_content = _convert_ansi_to_html(raw_content)
- else:
- escaped_content = escape_html(raw_content)
-
- # Build final HTML based on content length and presence of images
- if has_images:
- # Combine text and images
- text_html = f"{escaped_content}" if escaped_content else ""
- images_html = "".join(image_html_parts)
- combined_content = f"{text_html}{images_html}"
-
- # Always make collapsible when images are present
- preview_text = "Text and image content"
- return f"""
-
-
- {preview_text}
-
-
- {combined_content}
-
-
- """
- else:
- # Text-only content (existing behavior)
- # For simple content, show directly without collapsible wrapper
- if len(escaped_content) <= 200:
- return f"{escaped_content}"
-
- # For longer content, use collapsible details but no extra wrapper
- preview_text = escaped_content[:200] + "..."
- return f"""
-
-
- {preview_text}
-
-
- {escaped_content}
-
-
- """
-
-
-def _looks_like_bash_output(content: str) -> bool:
- """Check if content looks like it's from a Bash tool based on common patterns."""
- if not content:
- return False
-
- # Check for ANSI escape sequences
- if "\x1b[" in content:
- return True
-
- # Check for common bash/terminal patterns
- bash_indicators = [
- "$ ", # Shell prompt
- "❯ ", # Modern shell prompt
- "> ", # Shell continuation
- "\n+ ", # Bash -x output
- "bash: ", # Bash error messages
- "/bin/bash", # Bash path
- "command not found", # Common bash error
- "Permission denied", # Common bash error
- "No such file or directory", # Common bash error
- ]
-
- # Check for file path patterns that suggest command output
- import re
-
- if re.search(r"/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_.-]+)*", content): # Unix-style paths
- return True
-
- # Check for common command output patterns
- if any(indicator in content for indicator in bash_indicators):
- return True
-
- return False
-
-
-def format_thinking_content(thinking: ThinkingContent) -> str:
- """Format thinking content as HTML with markdown rendering."""
- thinking_text = thinking.thinking.strip()
-
- # Use line-based collapsible rendering (10 lines threshold, 5 preview)
- return render_markdown_collapsible(
- thinking_text, "thinking-text", line_threshold=10
- )
-
-
-def format_image_content(image: ImageContent) -> str:
- """Format image content as HTML."""
- # Create a data URL from the base64 image data
- data_url = f"data:{image.source.media_type};base64,{image.source.data}"
-
- return f'
'
-
-
-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, bool]:
- """Render user message content with IDE tag extraction and compacted summary handling.
-
- Returns:
- A tuple of (content_html, is_compacted, is_memory_input)
- """
- # 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):
- # Combine all text content for compacted summaries
- all_text = "\n\n".join(
- item.text for item in content_list if isinstance(item, TextContent)
- )
- # Render as collapsible markdown (threshold=30, preview=10 for large summaries)
- content_html = render_markdown_collapsible(
- all_text, "compacted-summary", line_threshold=30, preview_line_count=10
- )
- return content_html, True, False
-
- # Check for user memory input
- memory_match = re.search(
- r"(.*?) ",
- first_text,
- re.DOTALL,
- )
- if memory_match:
- memory_content = memory_match.group(1).strip()
- # Render the memory content as user message
- memory_content_list: List[ContentItem] = [
- TextContent(type="text", text=memory_content)
- ]
- content_html = render_message_content(memory_content_list, "user")
- return content_html, False, 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, 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[0].text)
- return "" + escaped_text + "
"
- else:
- # Assistant messages get markdown rendering with collapsible for long content
- return render_markdown_collapsible(
- content[0].text,
- "assistant-text",
- line_threshold=30,
- preview_line_count=10,
- )
-
- # content is a list of ContentItem objects
- rendered_parts: List[str] = []
-
- for item in content:
- # Handle both custom and Anthropic types
- item_type = getattr(item, "type", None)
-
- if type(item) is TextContent or (
- hasattr(item, "type") and hasattr(item, "text") and item_type == "text"
- ):
- # Handle both TextContent and Anthropic TextBlock
- text_value = getattr(item, "text", str(item))
- if message_type == "user":
- # User messages are shown as-is in preformatted blocks
- escaped_text = escape_html(text_value)
- rendered_parts.append("" + escaped_text + "
")
- else:
- # Assistant messages get markdown rendering with collapsible for long content
- rendered_parts.append(
- render_markdown_collapsible(
- text_value,
- "assistant-text",
- line_threshold=30,
- preview_line_count=10,
- )
- )
- elif type(item) is ToolUseContent or (
- hasattr(item, "type") and item_type == "tool_use"
- ):
- # Tool use items should not appear here - they are filtered out before this function
- print(
- "Warning: tool_use content should not be processed in render_message_content",
- flush=True,
- )
- elif type(item) is ToolResultContent or (
- hasattr(item, "type") and item_type == "tool_result"
- ):
- # Tool result items should not appear here - they are filtered out before this function
- print(
- "Warning: tool_result content should not be processed in render_message_content",
- flush=True,
- )
- elif type(item) is ThinkingContent or (
- hasattr(item, "type") and item_type == "thinking"
- ):
- # Thinking items should not appear here - they are filtered out before this function
- print(
- "Warning: thinking content should not be processed in render_message_content",
- flush=True,
- )
- elif type(item) is ImageContent:
- rendered_parts.append(format_image_content(item)) # type: ignore
- return "\n".join(rendered_parts)
+from .html import (
+ escape_html,
+ format_tool_use_title,
+ parse_bash_input,
+ parse_bash_output,
+ parse_command_output,
+ parse_slash_command,
+)
+from .parser import parse_user_message_content
-def _get_template_environment() -> Environment:
- """Get Jinja2 template environment."""
- templates_dir = Path(__file__).parent / "templates"
- 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
+# -- Content Formatters -------------------------------------------------------
+# NOTE: Content formatters have been moved to html/ submodules:
+# - format_thinking_content -> html/assistant_formatters.py
+# - format_assistant_text_content -> html/assistant_formatters.py
+# - format_tool_result_content -> html/tool_formatters.py
+# - format_tool_use_content -> html/tool_formatters.py
+# - format_image_content -> html/assistant_formatters.py
+# - format_user_text_model_content -> html/user_formatters.py
+# - parse_user_message_content -> parser.py
def _format_type_counts(type_counts: dict[str, int]) -> str:
@@ -1802,15 +161,16 @@ def _format_type_counts(type_counts: dict[str, int]) -> str:
return f"{parts[0]}, {parts[1]}, {remaining} more"
+# -- Template Classes ---------------------------------------------------------
+
+
class TemplateMessage:
"""Structured message data for template rendering."""
def __init__(
self,
message_type: str,
- content_html: str,
formatted_timestamp: str,
- css_class: str,
raw_timestamp: Optional[str] = None,
session_summary: Optional[str] = None,
session_id: Optional[str] = None,
@@ -1826,11 +186,14 @@ def __init__(
uuid: Optional[str] = None,
parent_uuid: Optional[str] = None,
agent_id: Optional[str] = None,
+ modifiers: Optional[MessageModifiers] = None,
+ content: Optional["MessageContent"] = None,
):
self.type = message_type
- self.content_html = content_html
+ # Structured content for rendering
+ self.content = content
self.formatted_timestamp = formatted_timestamp
- self.css_class = css_class
+ self.modifiers = modifiers if modifiers is not None else MessageModifiers()
self.raw_timestamp = raw_timestamp
# Display title for message header (capitalized, with decorations)
self.message_title = (
@@ -1864,6 +227,8 @@ def __init__(
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
+ # Children for tree-based rendering (future use)
+ self.children: List["TemplateMessage"] = []
def get_immediate_children_label(self) -> str:
"""Generate human-readable label for immediate children."""
@@ -1873,6 +238,30 @@ def get_total_descendants_label(self) -> str:
"""Generate human-readable label for all descendants."""
return _format_type_counts(self.total_descendants_by_type)
+ def flatten(self) -> List["TemplateMessage"]:
+ """Recursively flatten this message and all children into a list.
+
+ Returns a list with this message followed by all descendants in
+ depth-first order. This provides backward compatibility with the
+ flat-list template rendering approach.
+ """
+ result: List["TemplateMessage"] = [self]
+ for child in self.children:
+ result.extend(child.flatten())
+ return result
+
+ @staticmethod
+ def flatten_all(messages: List["TemplateMessage"]) -> List["TemplateMessage"]:
+ """Flatten a list of root messages into a single flat list.
+
+ Useful for converting a tree structure back to a flat list for
+ templates that expect the traditional flat message list.
+ """
+ result: List["TemplateMessage"] = []
+ for message in messages:
+ result.extend(message.flatten())
+ return result
+
class TemplateProject:
"""Structured project data for template rendering."""
@@ -2025,289 +414,216 @@ def __init__(self, project_summaries: List[Dict[str, Any]]):
self.token_summary = " | ".join(token_parts)
-def _render_hook_summary(message: "SystemTranscriptEntry") -> str:
- """Render a hook summary as collapsible details.
+# -- Template Generation ------------------------------------------------------
- Shows a compact summary with expandable hook commands and error output.
+
+def generate_template_messages(
+ messages: List[TranscriptEntry],
+) -> Tuple[List[TemplateMessage], List[Dict[str, Any]]]:
+ """Generate template messages and session navigation from transcript messages.
+
+ This is the format-neutral rendering step that produces data structures
+ ready for template rendering by any format-specific renderer.
+
+ Args:
+ messages: List of transcript entries to process.
+
+ Returns:
+ A tuple of (template_messages, session_nav) where:
+ - template_messages: Processed messages ready for template rendering
+ - session_nav: Session navigation data with summaries and metadata
"""
- # Extract command names from hookInfos
- commands = [info.get("command", "unknown") for info in (message.hookInfos or [])]
-
- # Determine if this is a failure or just output
- has_errors = bool(message.hookErrors)
- summary_icon = "🪝"
- summary_text = "Hook failed" if has_errors else "Hook output"
-
- # Build the command section
- command_html = ""
- if commands:
- command_html = ''
- for cmd in commands:
- # Truncate very long commands
- display_cmd = cmd if len(cmd) <= 100 else cmd[:97] + "..."
- command_html += f"{html.escape(display_cmd)}"
- command_html += ""
-
- # Build the error output section
- error_html = ""
- if message.hookErrors:
- error_html = ''
- for err in message.hookErrors:
- # Convert ANSI codes in error output
- formatted_err = _convert_ansi_to_html(err)
- error_html += f'{formatted_err}'
- error_html += ""
-
- return f"""
-{summary_icon} {summary_text}
-
-{command_html}
-{error_html}
-
-"""
-
-
-def _convert_ansi_to_html(text: str) -> str:
- """Convert ANSI escape codes to HTML spans with CSS classes.
-
- Supports:
- - Colors (30-37, 90-97 for foreground; 40-47, 100-107 for background)
- - RGB colors (38;2;r;g;b for foreground; 48;2;r;g;b for background)
- - Bold (1), Dim (2), Italic (3), Underline (4)
- - Reset (0, 39, 49, 22, 23, 24)
- - Strips cursor movement and screen manipulation codes
+ from .utils import get_warmup_session_ids
+
+ # Performance timing
+ t_start = time.time()
+
+ # Filter out warmup-only sessions
+ with log_timing("Filter warmup sessions", t_start):
+ warmup_session_ids = get_warmup_session_ids(messages)
+ if warmup_session_ids:
+ messages = [
+ msg
+ for msg in messages
+ if getattr(msg, "sessionId", None) not in warmup_session_ids
+ ]
+
+ # Pre-process to find and attach session summaries
+ with log_timing("Session summary processing", t_start):
+ prepare_session_summaries(messages)
+
+ # Filter messages (removes summaries, warmup, empty, etc.)
+ with log_timing("Filter messages", t_start):
+ filtered_messages = _filter_messages(messages)
+
+ # Pass 1: Collect session metadata and token tracking
+ with log_timing("Collect session info", t_start):
+ sessions, session_order, show_tokens_for_message = _collect_session_info(
+ filtered_messages
+ )
+
+ # Pass 2: Render messages to TemplateMessage objects
+ with log_timing(
+ lambda: f"Render messages ({len(template_messages)} messages)", t_start
+ ):
+ template_messages = _render_messages(
+ filtered_messages, sessions, show_tokens_for_message
+ )
+
+ # Prepare session navigation data
+ with log_timing(
+ lambda: f"Session navigation building ({len(session_nav)} sessions)", t_start
+ ):
+ session_nav = prepare_session_navigation(sessions, session_order)
+
+ # Reorder messages so each session's messages follow their session header
+ # This fixes interleaving that occurs when sessions are resumed
+ with log_timing("Reorder session messages", t_start):
+ template_messages = _reorder_session_template_messages(template_messages)
+
+ # Identify and mark paired messages (command+output, tool_use+tool_result, etc.)
+ with log_timing("Identify message pairs", t_start):
+ _identify_message_pairs(template_messages)
+
+ # Reorder messages so pairs are adjacent while preserving chronological order
+ with log_timing("Reorder paired messages", t_start):
+ template_messages = _reorder_paired_messages(template_messages)
+
+ # Reorder sidechains to appear after their Task results
+ # This must happen AFTER pair reordering, since that moves tool_results
+ with log_timing("Reorder sidechain messages", t_start):
+ template_messages = _reorder_sidechain_template_messages(template_messages)
+
+ # Build hierarchy (message_id and ancestry) based on final order
+ # This must happen AFTER all reordering to get correct parent-child relationships
+ with log_timing("Build message hierarchy", t_start):
+ _build_message_hierarchy(template_messages)
+
+ # Mark messages that have children for fold/unfold controls
+ with log_timing("Mark messages with children", t_start):
+ _mark_messages_with_children(template_messages)
+
+ # Build tree structure by populating children fields
+ # Returns root messages (typically session headers) with children populated
+ # HtmlRenderer flattens this via pre-order traversal for template rendering
+ with log_timing("Build message tree", t_start):
+ root_messages = _build_message_tree(template_messages)
+
+ return root_messages, session_nav
+
+
+# -- Session Utilities --------------------------------------------------------
+
+
+def prepare_session_summaries(messages: List[TranscriptEntry]) -> None:
+ """Pre-process messages to find and attach session summaries.
+
+ Modifies messages in place by attaching _session_summary attribute.
"""
- import re
-
- # First, strip cursor movement and screen manipulation codes
- # Common patterns: [1A (cursor up), [2K (erase line), [?25l (hide cursor), etc.
- cursor_patterns = [
- r"\x1b\[[0-9]*[ABCD]", # Cursor movement (up, down, forward, back)
- r"\x1b\[[0-9]*[EF]", # Cursor next/previous line
- r"\x1b\[[0-9]*[GH]", # Cursor horizontal/home position
- r"\x1b\[[0-9;]*[Hf]", # Cursor position
- r"\x1b\[[0-9]*[JK]", # Erase display/line
- r"\x1b\[[0-9]*[ST]", # Scroll up/down
- r"\x1b\[\?[0-9]*[hl]", # Private mode set/reset (show/hide cursor, etc.)
- r"\x1b\[[0-9]*[PXYZ@]", # Insert/delete operations
- r"\x1b\[=[0-9]*[A-Za-z]", # Alternate character set
- r"\x1b\][0-9];[^\x07]*\x07", # Operating System Command (OSC)
- r"\x1b\][0-9];[^\x1b]*\x1b\\", # OSC with string terminator
- ]
-
- # Strip all cursor movement and screen manipulation codes
- for pattern in cursor_patterns:
- text = re.sub(pattern, "", text)
-
- # Also strip any remaining unhandled escape sequences that aren't color codes
- # This catches any we might have missed, but preserves \x1b[...m color codes
- text = re.sub(r"\x1b\[(?![0-9;]*m)[0-9;]*[A-Za-z]", "", text)
-
- result: List[str] = []
- segments: List[Dict[str, Any]] = []
-
- # First pass: split text into segments with their styles
- last_end = 0
- current_fg = None
- current_bg = None
- current_bold = False
- current_dim = False
- current_italic = False
- current_underline = False
- current_rgb_fg = None
- current_rgb_bg = None
-
- for match in re.finditer(r"\x1b\[([0-9;]+)m", text):
- # Add text before this escape code
- if match.start() > last_end:
- segments.append(
- {
- "text": text[last_end : match.start()],
- "fg": current_fg,
- "bg": current_bg,
- "bold": current_bold,
- "dim": current_dim,
- "italic": current_italic,
- "underline": current_underline,
- "rgb_fg": current_rgb_fg,
- "rgb_bg": current_rgb_bg,
- }
- )
+ session_summaries: Dict[str, str] = {}
+ uuid_to_session: Dict[str, str] = {}
+ uuid_to_session_backup: Dict[str, str] = {}
- # Process escape codes
- codes = match.group(1).split(";")
- i = 0
- while i < len(codes):
- code = codes[i]
-
- # Reset codes
- if code == "0":
- current_fg = None
- current_bg = None
- current_bold = False
- current_dim = False
- current_italic = False
- current_underline = False
- current_rgb_fg = None
- current_rgb_bg = None
- elif code == "39":
- current_fg = None
- current_rgb_fg = None
- elif code == "49":
- current_bg = None
- current_rgb_bg = None
- elif code == "22":
- current_bold = False
- current_dim = False
- elif code == "23":
- current_italic = False
- elif code == "24":
- current_underline = False
-
- # Style codes
- elif code == "1":
- current_bold = True
- elif code == "2":
- current_dim = True
- elif code == "3":
- current_italic = True
- elif code == "4":
- current_underline = True
-
- # Standard foreground colors
- elif code in ["30", "31", "32", "33", "34", "35", "36", "37"]:
- color_map = {
- "30": "black",
- "31": "red",
- "32": "green",
- "33": "yellow",
- "34": "blue",
- "35": "magenta",
- "36": "cyan",
- "37": "white",
- }
- current_fg = f"ansi-{color_map[code]}"
- current_rgb_fg = None
-
- # Standard background colors
- elif code in ["40", "41", "42", "43", "44", "45", "46", "47"]:
- color_map = {
- "40": "black",
- "41": "red",
- "42": "green",
- "43": "yellow",
- "44": "blue",
- "45": "magenta",
- "46": "cyan",
- "47": "white",
- }
- current_bg = f"ansi-bg-{color_map[code]}"
- current_rgb_bg = None
-
- # Bright foreground colors
- elif code in ["90", "91", "92", "93", "94", "95", "96", "97"]:
- color_map = {
- "90": "bright-black",
- "91": "bright-red",
- "92": "bright-green",
- "93": "bright-yellow",
- "94": "bright-blue",
- "95": "bright-magenta",
- "96": "bright-cyan",
- "97": "bright-white",
- }
- current_fg = f"ansi-{color_map[code]}"
- current_rgb_fg = None
-
- # Bright background colors
- elif code in ["100", "101", "102", "103", "104", "105", "106", "107"]:
- color_map = {
- "100": "bright-black",
- "101": "bright-red",
- "102": "bright-green",
- "103": "bright-yellow",
- "104": "bright-blue",
- "105": "bright-magenta",
- "106": "bright-cyan",
- "107": "bright-white",
- }
- current_bg = f"ansi-bg-{color_map[code]}"
- current_rgb_bg = None
-
- # RGB foreground color
- elif code == "38" and i + 1 < len(codes) and codes[i + 1] == "2":
- if i + 4 < len(codes):
- r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
- current_rgb_fg = f"color: rgb({r}, {g}, {b})"
- current_fg = None
- i += 4
-
- # RGB background color
- elif code == "48" and i + 1 < len(codes) and codes[i + 1] == "2":
- if i + 4 < len(codes):
- r, g, b = codes[i + 2], codes[i + 3], codes[i + 4]
- current_rgb_bg = f"background-color: rgb({r}, {g}, {b})"
- current_bg = None
- i += 4
+ # Build mapping from message UUID to session ID
+ for message in messages:
+ if hasattr(message, "uuid") and hasattr(message, "sessionId"):
+ message_uuid = getattr(message, "uuid", "")
+ session_id = getattr(message, "sessionId", "")
+ if message_uuid and session_id:
+ # There is often duplication, in that case we want to prioritise the assistant
+ # message because summaries are generated from Claude's (last) success message
+ if type(message) is AssistantTranscriptEntry:
+ uuid_to_session[message_uuid] = session_id
+ else:
+ uuid_to_session_backup[message_uuid] = session_id
- i += 1
+ # Map summaries to sessions via leafUuid -> message UUID -> session ID
+ for message in messages:
+ if isinstance(message, SummaryTranscriptEntry):
+ leaf_uuid = message.leafUuid
+ if leaf_uuid in uuid_to_session:
+ session_summaries[uuid_to_session[leaf_uuid]] = message.summary
+ elif (
+ leaf_uuid in uuid_to_session_backup
+ and uuid_to_session_backup[leaf_uuid] not in session_summaries
+ ):
+ session_summaries[uuid_to_session_backup[leaf_uuid]] = message.summary
+
+ # Attach summaries to messages
+ for message in messages:
+ if hasattr(message, "sessionId"):
+ session_id = getattr(message, "sessionId", "")
+ if session_id in session_summaries:
+ setattr(message, "_session_summary", session_summaries[session_id])
+
+
+def prepare_session_navigation(
+ sessions: Dict[str, Dict[str, Any]],
+ session_order: List[str],
+) -> List[Dict[str, Any]]:
+ """Prepare session navigation data for template rendering.
+
+ Args:
+ sessions: Dictionary mapping session_id to session info dict
+ session_order: List of session IDs in display order
+
+ Returns:
+ List of session navigation dicts for template rendering
+ """
+ session_nav: List[Dict[str, Any]] = []
+
+ for session_id in session_order:
+ session_info = sessions[session_id]
+
+ # Skip empty sessions (agent-only, no user messages)
+ if not session_info["first_user_message"]:
+ continue
- last_end = match.end()
+ # Format timestamp range
+ first_ts = session_info["first_timestamp"]
+ last_ts = session_info["last_timestamp"]
+ timestamp_range = format_timestamp_range(first_ts, last_ts)
- # Add remaining text
- if last_end < len(text):
- segments.append(
+ # Format token usage summary
+ token_summary = ""
+ total_input = session_info["total_input_tokens"]
+ total_output = session_info["total_output_tokens"]
+ total_cache_creation = session_info["total_cache_creation_tokens"]
+ total_cache_read = session_info["total_cache_read_tokens"]
+
+ if total_input > 0 or total_output > 0:
+ token_parts: List[str] = []
+ if total_input > 0:
+ token_parts.append(f"Input: {total_input}")
+ if total_output > 0:
+ token_parts.append(f"Output: {total_output}")
+ if total_cache_creation > 0:
+ token_parts.append(f"Cache Creation: {total_cache_creation}")
+ if total_cache_read > 0:
+ token_parts.append(f"Cache Read: {total_cache_read}")
+ token_summary = "Token usage – " + " | ".join(token_parts)
+
+ session_nav.append(
{
- "text": text[last_end:],
- "fg": current_fg,
- "bg": current_bg,
- "bold": current_bold,
- "dim": current_dim,
- "italic": current_italic,
- "underline": current_underline,
- "rgb_fg": current_rgb_fg,
- "rgb_bg": current_rgb_bg,
+ "id": session_id,
+ "summary": session_info["summary"],
+ "timestamp_range": timestamp_range,
+ "first_timestamp": first_ts,
+ "last_timestamp": last_ts,
+ "message_count": session_info["message_count"],
+ "first_user_message": session_info["first_user_message"]
+ if session_info["first_user_message"] != ""
+ else "[No user message found in session.]",
+ "token_summary": token_summary,
}
)
- # Second pass: build HTML
- for segment in segments:
- if not segment["text"]:
- continue
+ return session_nav
- classes: List[str] = []
- styles: List[str] = []
-
- if segment["fg"]:
- classes.append(segment["fg"])
- if segment["bg"]:
- classes.append(segment["bg"])
- if segment["bold"]:
- classes.append("ansi-bold")
- if segment["dim"]:
- classes.append("ansi-dim")
- if segment["italic"]:
- classes.append("ansi-italic")
- if segment["underline"]:
- classes.append("ansi-underline")
- if segment["rgb_fg"]:
- styles.append(segment["rgb_fg"])
- if segment["rgb_bg"]:
- styles.append(segment["rgb_bg"])
-
- escaped_text = escape_html(segment["text"])
-
- if classes or styles:
- attrs: List[str] = []
- if classes:
- attrs.append(f'class="{" ".join(classes)}"')
- if styles:
- attrs.append(f'style="{"; ".join(styles)}"')
- result.append(f"{escaped_text}")
- else:
- result.append(escaped_text)
- return "".join(result)
+# -- Message Processing Functions ---------------------------------------------
+# Note: HTML formatting logic has been moved to html/content_formatters.py
+# as part of the refactoring to support format-neutral content models.
# def _process_summary_message(message: SummaryTranscriptEntry) -> tuple[str, str, str]:
@@ -2318,192 +634,72 @@ 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, 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)
- escaped_command_args = escape_html(command_args)
-
- # Format the command contents with proper line breaks
- formatted_contents = command_contents.replace("\\n", "\n")
- escaped_command_contents = escape_html(formatted_contents)
-
- # Build the content HTML
- content_parts: List[str] = [f"Command: {escaped_command_name}"]
- if command_args:
- content_parts.append(f"Args: {escaped_command_args}")
- if command_contents:
- lines = escaped_command_contents.splitlines()
- line_count = len(lines)
- if line_count <= 12:
- # Short content, show inline
- details_html = (
- f"Content:{escaped_command_contents}"
- )
- else:
- # Long content, make collapsible
- preview = "\n".join(lines[:5])
- collapsible = render_collapsible_code(
- f"{preview}",
- f"{escaped_command_contents}",
- line_count,
- )
- details_html = f"Content:{collapsible}"
- content_parts.append(details_html)
-
- content_html = "
".join(content_parts)
- message_type = "system"
- message_title = "System"
- return css_class, content_html, message_type, message_title
-
+def _process_command_message(
+ text_content: str,
+) -> tuple[MessageModifiers, Optional["MessageContent"], str, str]:
+ """Process a slash command message and return (modifiers, content, message_type, message_title).
-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
+ These are user messages containing slash command invocations (e.g., /context, /model).
+ The JSONL type is "user", not "system".
+ """
+ modifiers = MessageModifiers(is_slash_command=True)
- css_class = "system command-output"
+ # Parse to content model (formatting happens in HtmlRenderer)
+ content = parse_slash_command(text_content)
+ # If parsing fails, content will be None and caller will handle fallback
- stdout_match = re.search(
- r"(.*?) ",
- text_content,
- re.DOTALL,
- )
- if stdout_match:
- stdout_content = stdout_match.group(1).strip()
+ message_type = "user"
+ message_title = "Slash Command"
+ return modifiers, content, message_type, message_title
- # 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
+def _process_local_command_output(
+ text_content: str,
+) -> tuple[MessageModifiers, Optional["MessageContent"], str, str]:
+ """Process slash command output and return (modifiers, content, message_type, message_title).
- 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)
+ These are user messages containing the output from slash commands (e.g., /context, /model).
+ The JSONL type is "user", not "system".
+ """
+ modifiers = MessageModifiers(is_command_output=True)
- message_type = "system"
- message_title = "System"
- return css_class, content_html, message_type, message_title
+ # Parse to content model (formatting happens in HtmlRenderer)
+ content = parse_command_output(text_content)
+ # If parsing fails, content will be None and caller will handle fallback
+ message_type = "user"
+ message_title = "Command Output"
+ return modifiers, content, message_type, message_title
-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"
+def _process_bash_input(
+ text_content: str,
+) -> tuple[MessageModifiers, Optional["MessageContent"], str, str]:
+ """Process bash input command and return (modifiers, content, message_type, message_title)."""
+ modifiers = MessageModifiers() # bash-input is a message type, not a modifier
- bash_match = re.search(
- r"(.*?) ",
- text_content,
- re.DOTALL,
- )
- if bash_match:
- bash_command = bash_match.group(1).strip()
- escaped_command = escape_html(bash_command)
- content_html = (
- f"❯ "
- f"{escaped_command}"
- )
- else:
- content_html = escape_html(text_content)
+ # Parse to content model (formatting happens in HtmlRenderer)
+ content = parse_bash_input(text_content)
+ # If parsing fails, content will be None and caller will handle fallback
- message_type = "bash"
+ message_type = "bash-input"
message_title = "Bash"
- return css_class, content_html, message_type, message_title
-
-
-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"
- COLLAPSE_THRESHOLD = 10 # Collapse if more than this many lines
-
- stdout_match = re.search(
- r"(.*?) ",
- text_content,
- re.DOTALL,
- )
- stderr_match = re.search(
- r"(.*?) ",
- text_content,
- re.DOTALL,
- )
+ return modifiers, content, message_type, message_title
- output_parts: List[tuple[str, str, int, str]] = []
- total_lines = 0
-
- if stdout_match:
- stdout_content = stdout_match.group(1).strip()
- if stdout_content:
- escaped_stdout = _convert_ansi_to_html(stdout_content)
- stdout_lines = stdout_content.count("\n") + 1
- total_lines += stdout_lines
- output_parts.append(
- ("stdout", escaped_stdout, stdout_lines, stdout_content)
- )
- if stderr_match:
- stderr_content = stderr_match.group(1).strip()
- if stderr_content:
- escaped_stderr = _convert_ansi_to_html(stderr_content)
- stderr_lines = stderr_content.count("\n") + 1
- total_lines += stderr_lines
- output_parts.append(
- ("stderr", escaped_stderr, stderr_lines, stderr_content)
- )
+def _process_bash_output(
+ text_content: str,
+) -> tuple[MessageModifiers, Optional["MessageContent"], str, str]:
+ """Process bash output and return (modifiers, content, message_type, message_title)."""
+ modifiers = MessageModifiers() # bash-output is a message type, not a modifier
- if output_parts:
- # Build the HTML parts
- html_parts: List[str] = []
- for output_type, escaped_content, _, _ in output_parts:
- css_name = f"bash-{output_type}"
- html_parts.append(f"{escaped_content}")
-
- full_html = "".join(html_parts)
-
- # Wrap in collapsible if output is large
- if total_lines > COLLAPSE_THRESHOLD:
- # Create preview (first few lines)
- preview_lines = 3
- first_output = output_parts[0]
- raw_preview = "\n".join(first_output[3].split("\n")[:preview_lines])
- preview_html = html.escape(raw_preview)
- if total_lines > preview_lines:
- preview_html += "\n..."
-
- content_html = f"""
-
- {total_lines} lines
- {preview_html}
-
- {full_html}
- """
- else:
- content_html = full_html
- else:
- # Empty output
- content_html = (
- "(no output)"
- )
+ # Parse to content model (formatting happens in HtmlRenderer)
+ content = parse_bash_output(text_content)
+ # If parsing fails, content will be None - caller/renderer handles empty output
message_type = "bash"
message_title = "Bash"
- return css_class, content_html, message_type, message_title
+ return modifiers, content, message_type, message_title
def _process_regular_message(
@@ -2511,8 +707,11 @@ def _process_regular_message(
message_type: str,
is_sidechain: bool,
is_meta: bool = False,
-) -> tuple[str, str, str, str]:
- """Process regular message and return (css_class, content_html, message_type, message_title).
+) -> tuple[MessageModifiers, Optional["MessageContent"], str, str]:
+ """Process regular message and return (modifiers, content_model, message_type, message_title).
+
+ Returns content_model for user messages, None for non-user messages.
+ Non-user messages (assistant) are handled by the legacy render_message_content path.
Note: Sidechain user messages (Sub-assistant prompts) are now skipped entirely
in the main processing loop since they duplicate the Task tool input prompt.
@@ -2520,17 +719,18 @@ def _process_regular_message(
Args:
is_meta: True for slash command expanded prompts (isMeta=True in JSONL)
"""
- css_class = f"{message_type}"
message_title = message_type.title() # Default title
is_compacted = False
+ is_slash_command = False
+ content_model: Optional["MessageContent"] = None
# Handle user-specific preprocessing
- if message_type == "user":
+ if message_type == MessageType.USER:
# Note: sidechain user messages are skipped before reaching this function
if is_meta:
- # Slash command expanded prompts - render as collapsible markdown
+ # Slash command expanded prompts
# These contain LLM-generated instruction text (markdown formatted)
- css_class = f"{message_type} slash-command"
+ is_slash_command = True
message_title = "User (slash command)"
# Combine all text content (items may be TextContent, dicts, or SDK objects)
all_text = "\n\n".join(
@@ -2538,43 +738,400 @@ def _process_regular_message(
for item in text_only_content
if hasattr(item, "text")
)
- content_html = render_markdown_collapsible(
- all_text,
- "slash-command-content",
- line_threshold=20,
- preview_line_count=5,
- )
+ # Use UserTextContent with is_slash_command flag for HtmlRenderer to format
+ content_model = UserTextContent(text=all_text)
else:
- content_html, is_compacted, is_memory_input = render_user_message_content(
- text_only_content
- )
- if is_compacted:
- css_class = f"{message_type} compacted"
+ content_model = parse_user_message_content(text_only_content)
+ # Determine message_title and modifiers from content type
+ if isinstance(content_model, CompactedSummaryContent):
+ is_compacted = True
message_title = "User (compacted conversation)"
- elif is_memory_input:
+ elif isinstance(content_model, UserMemoryContent):
message_title = "Memory"
+ elif message_type == MessageType.ASSISTANT:
+ # Create AssistantTextContent for assistant messages
+ all_text = "\n\n".join(
+ getattr(item, "text", "")
+ for item in text_only_content
+ if hasattr(item, "text")
+ )
+ if all_text:
+ content_model = AssistantTextContent(text=all_text)
+
+ if is_sidechain:
+ # Update message title for display (only non-user types reach here)
+ if not is_compacted:
+ message_title = "🔗 Sub-assistant"
+
+ modifiers = MessageModifiers(
+ is_sidechain=is_sidechain,
+ is_slash_command=is_slash_command,
+ is_compacted=is_compacted,
+ )
+
+ return modifiers, content_model, message_type, message_title
+
+
+def _process_system_message(
+ message: SystemTranscriptEntry,
+) -> Optional[TemplateMessage]:
+ """Process a system message and return a TemplateMessage, or None if it should be skipped.
+
+ Handles:
+ - Hook summaries (subtype="stop_hook_summary")
+ - Other system messages with level-specific styling (info, warning, error)
+
+ Note: Slash command messages (, ) are user messages,
+ not system messages. They are handled by _process_command_message and
+ _process_local_command_output in the main processing loop.
+ """
+ from .models import MessageContent # Local import to avoid circular dependency
+
+ session_id = getattr(message, "sessionId", "unknown")
+ timestamp = getattr(message, "timestamp", "")
+ formatted_timestamp = format_timestamp(timestamp) if timestamp else ""
+
+ # Build structured content based on message subtype
+ content: MessageContent
+ if message.subtype == "stop_hook_summary":
+ # Skip silent hook successes (no output, no errors)
+ if not message.hasOutput and not message.hookErrors:
+ return None
+ # Create structured hook summary content
+ hook_infos = [
+ HookInfo(command=info.get("command", "unknown"))
+ for info in (message.hookInfos or [])
+ ]
+ content = HookSummaryContent(
+ has_output=bool(message.hasOutput),
+ hook_errors=message.hookErrors or [],
+ hook_infos=hook_infos,
+ )
+ level = "hook"
+ elif not message.content:
+ # Skip system messages without content (shouldn't happen normally)
+ return None
+ else:
+ # Create structured system content
+ level = getattr(message, "level", "info")
+ content = SystemContent(level=level, text=message.content)
+
+ # Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy)
+ parent_uuid = getattr(message, "parentUuid", None)
+
+ return TemplateMessage(
+ message_type="system",
+ formatted_timestamp=formatted_timestamp,
+ raw_timestamp=timestamp,
+ session_id=session_id,
+ message_title=f"System {level.title()}",
+ message_id=None, # Will be assigned by _build_message_hierarchy
+ ancestry=[], # Will be assigned by _build_message_hierarchy
+ uuid=message.uuid,
+ parent_uuid=parent_uuid,
+ modifiers=MessageModifiers(system_level=level),
+ content=content,
+ )
+
+
+@dataclass
+class ToolItemResult:
+ """Result of processing a single tool/thinking/image item."""
+
+ message_type: str
+ message_title: str
+ content: Optional["MessageContent"] = None # Structured content for rendering
+ tool_use_id: Optional[str] = None
+ title_hint: Optional[str] = None
+ pending_dedup: Optional[str] = None # For Task result deduplication
+ is_error: bool = False # For tool_result error state
+
+
+def _process_tool_use_item(
+ tool_item: ContentItem,
+ tool_use_context: Dict[str, ToolUseContent],
+) -> Optional[ToolItemResult]:
+ """Process a tool_use content item.
+
+ Args:
+ tool_item: The tool use content item
+ tool_use_context: Dict to populate with tool_use_id -> ToolUseContent mapping
+
+ Returns:
+ ToolItemResult with tool_use content model, or None if item should be skipped
+ """
+ # Convert Anthropic type to our format if necessary
+ if not isinstance(tool_item, ToolUseContent):
+ tool_use = ToolUseContent(
+ type="tool_use",
+ id=getattr(tool_item, "id", ""),
+ name=getattr(tool_item, "name", ""),
+ input=getattr(tool_item, "input", {}),
+ )
+ else:
+ tool_use = tool_item
+
+ # Title is computed here but content formatting happens in HtmlRenderer
+ tool_message_title = format_tool_use_title(tool_use)
+ escaped_id = escape_html(tool_use.id)
+ item_tool_use_id = tool_use.id
+ tool_title_hint = f"ID: {escaped_id}"
+
+ # Populate tool_use_context for later use when processing tool results
+ tool_use_context[item_tool_use_id] = tool_use
+
+ return ToolItemResult(
+ message_type="tool_use",
+ message_title=tool_message_title,
+ content=tool_use, # ToolUseContent is the model
+ tool_use_id=item_tool_use_id,
+ title_hint=tool_title_hint,
+ )
+
+
+def _process_tool_result_item(
+ tool_item: ContentItem,
+ tool_use_context: Dict[str, ToolUseContent],
+) -> Optional[ToolItemResult]:
+ """Process a tool_result content item.
+
+ Args:
+ tool_item: The tool result content item
+ tool_use_context: Dict with tool_use_id -> ToolUseContent mapping
+
+ Returns:
+ ToolItemResult with tool_result content model, or None if item should be skipped
+ """
+ # Convert Anthropic type to our format if necessary
+ if not isinstance(tool_item, ToolResultContent):
+ tool_result = ToolResultContent(
+ type="tool_result",
+ tool_use_id=getattr(tool_item, "tool_use_id", ""),
+ content=getattr(tool_item, "content", ""),
+ is_error=getattr(tool_item, "is_error", False),
+ )
else:
- # Non-user messages: render directly
- content_html = render_message_content(text_only_content, message_type)
+ tool_result = tool_item
+
+ # 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.tool_use_id in tool_use_context:
+ tool_use_from_ctx = tool_use_context[tool_result.tool_use_id]
+ result_tool_name = tool_use_from_ctx.name
+ if (
+ result_tool_name in ("Read", "Edit", "Write")
+ and "file_path" in tool_use_from_ctx.input
+ ):
+ result_file_path = tool_use_from_ctx.input["file_path"]
+
+ # Create content model with rendering context
+ content_model = ToolResultContentModel(
+ tool_use_id=tool_result.tool_use_id,
+ content=tool_result.content,
+ is_error=tool_result.is_error or False,
+ tool_name=result_tool_name,
+ file_path=result_file_path,
+ )
+
+ # Retroactive deduplication: if Task result, extract content for later matching
+ pending_dedup: Optional[str] = None
+ if result_tool_name == "Task":
+ # Extract text content from tool result
+ # Note: tool_result.content can be str or List[Dict[str, Any]]
+ if isinstance(tool_result.content, str):
+ task_result_content = tool_result.content.strip()
+ else:
+ # Handle list of dicts (tool result format)
+ content_parts: list[str] = []
+ for item in tool_result.content:
+ text_val = item.get("text", "")
+ if isinstance(text_val, str):
+ content_parts.append(text_val)
+ task_result_content = "\n".join(content_parts).strip()
+ pending_dedup = task_result_content if task_result_content else None
+
+ escaped_id = escape_html(tool_result.tool_use_id)
+ tool_title_hint = f"ID: {escaped_id}"
+ tool_message_title = "Error" if tool_result.is_error else ""
+
+ return ToolItemResult(
+ message_type="tool_result",
+ message_title=tool_message_title,
+ content=content_model,
+ tool_use_id=tool_result.tool_use_id,
+ title_hint=tool_title_hint,
+ pending_dedup=pending_dedup,
+ is_error=tool_result.is_error or False,
+ )
+
+
+def _process_thinking_item(tool_item: ContentItem) -> Optional[ToolItemResult]:
+ """Process a thinking content item.
+
+ Returns:
+ ToolItemResult with thinking content model
+ """
+ # Extract thinking text from the content item
+ if isinstance(tool_item, ThinkingContent):
+ thinking_text = tool_item.thinking.strip()
+ signature = getattr(tool_item, "signature", None)
+ else:
+ thinking_text = getattr(tool_item, "thinking", str(tool_item)).strip()
+ signature = None
+
+ # Create the content model (formatting happens in HtmlRenderer)
+ thinking_model = ThinkingContentModel(thinking=thinking_text, signature=signature)
+
+ return ToolItemResult(
+ message_type="thinking",
+ message_title="Thinking",
+ content=thinking_model,
+ )
+
+
+def _process_image_item(tool_item: ContentItem) -> Optional[ToolItemResult]:
+ """Process an image content item.
+
+ Returns:
+ ToolItemResult with image content model, or None if item should be skipped
+ """
+ # Convert Anthropic type to our format if necessary
+ if not isinstance(tool_item, ImageContent):
+ # For now, skip Anthropic image types - we'll handle when we encounter them
+ return None
+
+ return ToolItemResult(
+ message_type="image",
+ message_title="Image",
+ content=tool_item, # ImageContent is already the model
+ )
+
+
+# -- Message Pairing ----------------------------------------------------------
+
+
+@dataclass
+class PairingIndices:
+ """Indices for efficient message pairing lookups.
+
+ All indices are built in a single pass for efficiency.
+ """
+
+ # (session_id, tool_use_id) -> message index for tool_use messages
+ tool_use: Dict[tuple[str, str], int]
+ # (session_id, tool_use_id) -> message index for tool_result messages
+ tool_result: Dict[tuple[str, str], int]
+ # uuid -> message index for system messages (parent-child pairing)
+ uuid: Dict[str, int]
+ # parent_uuid -> message index for slash-command messages
+ slash_command_by_parent: Dict[str, int]
+
+
+def _build_pairing_indices(messages: List[TemplateMessage]) -> PairingIndices:
+ """Build indices for efficient message pairing lookups.
+
+ Single pass through messages to build all indices needed for pairing.
+ """
+ tool_use_index: Dict[tuple[str, str], int] = {}
+ tool_result_index: Dict[tuple[str, str], int] = {}
+ uuid_index: Dict[str, int] = {}
+ slash_command_by_parent: Dict[str, int] = {}
+
+ for i, msg in enumerate(messages):
+ # Index tool_use and tool_result by (session_id, tool_use_id)
+ if msg.tool_use_id and msg.session_id:
+ key = (msg.session_id, msg.tool_use_id)
+ if msg.type == "tool_use":
+ tool_use_index[key] = i
+ elif msg.type == "tool_result":
+ tool_result_index[key] = i
+
+ # Index system messages by UUID for parent-child pairing
+ if msg.uuid and msg.type == "system":
+ uuid_index[msg.uuid] = i
+
+ # Index slash-command user messages by parent_uuid
+ if msg.parent_uuid and msg.modifiers.is_slash_command:
+ slash_command_by_parent[msg.parent_uuid] = i
+
+ return PairingIndices(
+ tool_use=tool_use_index,
+ tool_result=tool_result_index,
+ uuid=uuid_index,
+ slash_command_by_parent=slash_command_by_parent,
+ )
+
- if is_sidechain:
- css_class = f"{css_class} sidechain"
- # Update message title for display (only non-user types reach here)
- if not is_compacted:
- message_title = "🔗 Sub-assistant"
+def _mark_pair(first: TemplateMessage, last: TemplateMessage) -> None:
+ """Mark two messages as a pair."""
+ first.is_paired = True
+ first.pair_role = "pair_first"
+ last.is_paired = True
+ last.pair_role = "pair_last"
- return css_class, content_html, message_type, message_title
+def _try_pair_adjacent(
+ current: TemplateMessage,
+ next_msg: TemplateMessage,
+) -> bool:
+ """Try to pair adjacent messages based on their types.
+
+ Returns True if messages were paired, False otherwise.
+
+ Adjacent pairing rules:
+ - user slash-command + user command-output
+ - bash-input + bash-output
+ - thinking + assistant
+ """
+ # Slash command + command output (both are user messages)
+ if current.modifiers.is_slash_command and next_msg.modifiers.is_command_output:
+ _mark_pair(current, next_msg)
+ return True
+
+ # Bash input + bash output
+ if current.type == "bash-input" and next_msg.type == "bash-output":
+ _mark_pair(current, next_msg)
+ return True
+
+ # Thinking + assistant
+ if current.type == "thinking" and next_msg.type == "assistant":
+ _mark_pair(current, next_msg)
+ return True
+
+ return False
-def _get_combined_transcript_link(cache_manager: "CacheManager") -> Optional[str]:
- """Get link to combined transcript if available."""
- try:
- project_cache = cache_manager.get_cached_project_data()
- if project_cache and project_cache.sessions:
- return "combined_transcripts.html"
- return None
- except Exception:
- return None
+
+def _try_pair_by_index(
+ current: TemplateMessage,
+ messages: List[TemplateMessage],
+ indices: PairingIndices,
+) -> None:
+ """Try to pair current message with another using index lookups.
+
+ Index-based pairing rules (can be any distance apart):
+ - tool_use + tool_result (by tool_use_id within same session)
+ - system parent + system child (by uuid/parent_uuid)
+ - system + slash-command (by uuid -> parent_uuid)
+ """
+ # Tool use + tool result (by tool_use_id within same session)
+ if current.type == "tool_use" and current.tool_use_id and current.session_id:
+ key = (current.session_id, current.tool_use_id)
+ if key in indices.tool_result:
+ result_msg = messages[indices.tool_result[key]]
+ _mark_pair(current, result_msg)
+
+ # System child message finding its parent (by parent_uuid)
+ if current.type == "system" and current.parent_uuid:
+ if current.parent_uuid in indices.uuid:
+ parent_msg = messages[indices.uuid[current.parent_uuid]]
+ _mark_pair(parent_msg, current)
+
+ # System command finding its slash-command child (by uuid -> parent_uuid)
+ if current.type == "system" and current.uuid:
+ if current.uuid in indices.slash_command_by_parent:
+ slash_msg = messages[indices.slash_command_by_parent[current.uuid]]
+ _mark_pair(current, slash_msg)
def _identify_message_pairs(messages: List[TemplateMessage]) -> None:
@@ -2583,39 +1140,15 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None:
Modifies messages in-place by setting is_paired and pair_role fields.
Uses a two-pass algorithm:
- 1. First pass: Build index of (session_id, tool_use_id) -> message index for tool_use
- and tool_result. Session ID is included to prevent cross-session pairing
- when sessions are resumed (same tool_use_id can appear in multiple sessions).
- Build index of uuid -> message index for parent-child system messages
- Build index of parent_uuid -> message index for slash-command messages
- 2. Second pass: Sequential scan for adjacent pairs (system+output, bash, thinking+assistant)
- and match tool_use/tool_result and uuid-based pairs using the index
- """
- # Pass 1: Build index of tool_use messages and tool_result messages
- # Key is (session_id, tool_use_id) to prevent cross-session pairing on resume
- tool_use_index: Dict[
- tuple[str, str], int
- ] = {} # (session_id, tool_use_id) -> index
- tool_result_index: Dict[
- tuple[str, str], int
- ] = {} # (session_id, tool_use_id) -> index
- uuid_index: Dict[str, int] = {} # uuid -> message index for parent-child pairing
- # Index slash-command messages by their parent_uuid for pairing with system commands
- slash_command_by_parent: Dict[str, int] = {} # parent_uuid -> message index
+ 1. First pass: Build indices for efficient lookups (tool_use_id, uuid, parent_uuid)
+ 2. Second pass: Sequential scan for adjacent pairs and index-based pairs
- for i, msg in enumerate(messages):
- if msg.tool_use_id and msg.session_id:
- key = (msg.session_id, msg.tool_use_id)
- if "tool_use" in msg.css_class:
- tool_use_index[key] = i
- elif "tool_result" in msg.css_class:
- tool_result_index[key] = i
- # Build UUID index for system messages (both parent and child)
- if msg.uuid and "system" in msg.css_class:
- uuid_index[msg.uuid] = i
- # Index slash-command user messages by parent_uuid
- if msg.parent_uuid and "slash-command" in msg.css_class:
- slash_command_by_parent[msg.parent_uuid] = i
+ Pairing types:
+ - Adjacent: system+output, bash-input+output, thinking+assistant
+ - Indexed: tool_use+result (by ID), system parent+child (by UUID)
+ """
+ # Pass 1: Build all indices for efficient lookups
+ indices = _build_pairing_indices(messages)
# Pass 2: Sequential scan to identify pairs
i = 0
@@ -2627,75 +1160,15 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None:
i += 1
continue
- # 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:
- 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 using index (no distance limit)
- # Key includes session_id to prevent cross-session pairing on resume
- if (
- "tool_use" in current.css_class
- and current.tool_use_id
- and current.session_id
- ):
- key = (current.session_id, current.tool_use_id)
- if key in tool_result_index:
- result_idx = tool_result_index[key]
- 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 UUID-based parent-child system message pair (no distance limit)
- if "system" in current.css_class and current.parent_uuid:
- if current.parent_uuid in uuid_index:
- parent_idx = uuid_index[current.parent_uuid]
- parent_msg = messages[parent_idx]
- parent_msg.is_paired = True
- parent_msg.pair_role = "pair_first"
- current.is_paired = True
- current.pair_role = "pair_last"
-
- # Check for system command + user slash-command pair (via parent_uuid)
- # The slash-command message's parent_uuid points to the system command's uuid
- if "system" in current.css_class and current.uuid:
- if current.uuid in slash_command_by_parent:
- slash_idx = slash_command_by_parent[current.uuid]
- slash_msg = messages[slash_idx]
- current.is_paired = True
- current.pair_role = "pair_first"
- slash_msg.is_paired = True
- slash_msg.pair_role = "pair_last"
-
- # Check for bash-input + bash-output pair (adjacent only)
- if current.css_class == "bash-input" and i + 1 < len(messages):
+ # Try adjacent pairing first (can skip next message if paired)
+ if 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"
+ if _try_pair_adjacent(current, next_msg):
i += 2
continue
- # 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:
- current.is_paired = True
- current.pair_role = "pair_first"
- next_msg.is_paired = True
- next_msg.pair_role = "pair_last"
- i += 2
- continue
+ # Try index-based pairing (doesn't skip, continues to next message)
+ _try_pair_by_index(current, messages, indices)
i += 1
@@ -2736,7 +1209,7 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe
msg.is_paired
and msg.pair_role == "pair_last"
and msg.parent_uuid
- and "slash-command" in msg.css_class
+ and msg.modifiers.is_slash_command
):
slash_command_pair_index[msg.parent_uuid] = i
@@ -2768,7 +1241,13 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe
last_idx = slash_command_pair_index[msg.uuid]
pair_last = messages[last_idx]
- if pair_last is not None and last_idx is not None:
+ # Only append if we haven't already added this pair_last
+ # (handles case where multiple pair_firsts match the same pair_last)
+ if (
+ pair_last is not None
+ and last_idx is not None
+ and last_idx not in skip_indices
+ ):
reordered.append(pair_last)
skip_indices.add(last_idx)
@@ -2803,42 +1282,11 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe
return reordered
-def generate_session_html(
- messages: List[TranscriptEntry],
- session_id: str,
- title: Optional[str] = None,
- cache_manager: Optional["CacheManager"] = None,
-) -> str:
- """Generate HTML for a single session using Jinja2 templates."""
- # Filter messages for this session only
- session_messages = [
- msg
- for msg in messages
- if hasattr(msg, "sessionId") and getattr(msg, "sessionId") == session_id
- ]
-
- # Get combined transcript link if cache manager is available
- combined_link = None
- if cache_manager is not None:
- combined_link = _get_combined_transcript_link(cache_manager)
-
- if not session_messages:
- return generate_html(
- [],
- title or f"Session {session_id[:8]}",
- combined_transcript_link=combined_link,
- )
-
- # Use the existing generate_html function but with filtered messages and combined link
- return generate_html(
- session_messages,
- title or f"Session {session_id[:8]}",
- combined_transcript_link=combined_link,
- )
+# -- Message Hierarchy --------------------------------------------------------
-def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int:
- """Determine the hierarchy level for a message based on its type and sidechain status.
+def _get_message_hierarchy_level(msg: TemplateMessage) -> int:
+ """Determine the hierarchy level for a message based on its type and modifiers.
Correct hierarchy based on logical nesting:
- Level 0: Session headers
@@ -2854,35 +1302,41 @@ def _get_message_hierarchy_level(css_class: str, is_sidechain: bool) -> int:
Returns:
Integer hierarchy level (1-5, session headers are 0)
"""
+ msg_type = msg.type
+ is_sidechain = msg.modifiers.is_sidechain
+ system_level = msg.modifiers.system_level
+
# User messages at level 1 (under session)
# Note: sidechain user messages are skipped before reaching this function
- if "user" in css_class and not is_sidechain:
+ if msg_type == "user" and not is_sidechain:
return 1
# System info/warning at level 3 (tool-related, e.g., hook notifications)
if (
- "system-info" in css_class or "system-warning" in css_class
- ) and not is_sidechain:
+ msg_type == "system"
+ and system_level in ("info", "warning")
+ and not is_sidechain
+ ):
return 3
# System commands/errors at level 2 (siblings to assistant)
- if "system" in css_class and not is_sidechain:
+ if msg_type == "system" and not is_sidechain:
return 2
# Sidechain assistant/thinking at level 4 (nested under Task tool result)
- if is_sidechain and ("assistant" in css_class or "thinking" in css_class):
+ if is_sidechain and msg_type in ("assistant", "thinking"):
return 4
# Sidechain tools at level 5
- if is_sidechain and ("tool" in css_class):
+ if is_sidechain and msg_type in ("tool_use", "tool_result"):
return 5
# Main assistant/thinking at level 2 (nested under user)
- if "assistant" in css_class or "thinking" in css_class:
+ if msg_type in ("assistant", "thinking"):
return 2
# Main tools at level 3 (nested under assistant)
- if "tool" in css_class:
+ if msg_type in ("tool_use", "tool_result"):
return 3
# Default to level 1
@@ -2909,11 +1363,8 @@ def _build_message_hierarchy(messages: List[TemplateMessage]) -> None:
if message.is_session_header:
current_level = 0
else:
- # Determine level from css_class
- is_sidechain = "sidechain" in message.css_class
- current_level = _get_message_hierarchy_level(
- message.css_class, is_sidechain
- )
+ # Determine level from message type and modifiers
+ current_level = _get_message_hierarchy_level(message)
# Pop stack until we find the appropriate parent level
while hierarchy_stack and hierarchy_stack[-1][0] >= current_level:
@@ -2971,7 +1422,7 @@ def _mark_messages_with_children(messages: List[TemplateMessage]) -> None:
immediate_parent_id = message.ancestry[-1]
# Get message type for categorization
- msg_type = message.css_class or message.type
+ msg_type = message.type
# Increment immediate parent's child count
if immediate_parent_id in message_by_id:
@@ -2994,249 +1445,54 @@ def _mark_messages_with_children(messages: List[TemplateMessage]) -> None:
)
-def deduplicate_messages(messages: List[TranscriptEntry]) -> List[TranscriptEntry]:
- """Remove duplicate messages based on (type, timestamp, sessionId, content_key).
+def _build_message_tree(messages: List[TemplateMessage]) -> List[TemplateMessage]:
+ """Build tree structure by populating children fields based on ancestry.
- Messages with the exact same timestamp are duplicates by definition -
- the differences (like IDE selection tags) are just logging artifacts.
+ This function takes a flat list of messages (with message_id and ancestry
+ already set by _build_message_hierarchy) and populates the children field
+ of each message to form an explicit tree structure.
- We need a content-based key to handle two cases:
- 1. Version stutter: Same message logged twice during Claude Code upgrade
- -> Same timestamp, same message.id or tool_use_id -> SHOULD deduplicate
- 2. Concurrent tool results: Multiple tool results with same timestamp
- -> Same timestamp, different tool_use_ids -> should NOT deduplicate
+ The tree structure enables:
+ - Recursive template rendering with nested DOM elements
+ - Simpler JavaScript fold/unfold (just hide/show children container)
+ - More natural parent-child traversal
Args:
- messages: List of transcript entries to deduplicate
+ messages: List of template messages with message_id and ancestry set
Returns:
- List of deduplicated messages, preserving order (first occurrence kept)
+ List of root messages (those with empty ancestry). Each message's
+ children field is populated with its direct children.
"""
- # Track seen (message_type, timestamp, is_meta, session_id, content_key) tuples
- seen: set[tuple[str, str, bool, str, str]] = set()
- deduplicated: List[TranscriptEntry] = []
-
+ # Build index of messages by ID for O(1) lookup
+ message_by_id: dict[str, TemplateMessage] = {}
for message in messages:
- # Get basic message type
- message_type = getattr(message, "type", "unknown")
-
- # For system messages, include level to differentiate info/warning/error
- if isinstance(message, SystemTranscriptEntry):
- level = getattr(message, "level", "info")
- message_type = f"system-{level}"
-
- # Get timestamp
- timestamp = getattr(message, "timestamp", "")
-
- # Get isMeta flag (slash command prompts have isMeta=True with same timestamp as parent)
- is_meta = getattr(message, "isMeta", False)
-
- # Get sessionId for multi-session report deduplication
- session_id = getattr(message, "sessionId", "")
-
- # Get content key for differentiating concurrent messages
- # - For assistant messages: use message.id (same for stutters, different for different msgs)
- # - For user messages with tool results: use first tool_use_id
- # - For other messages: use uuid as fallback
- content_key = ""
- if isinstance(message, AssistantTranscriptEntry):
- # For assistant messages, use the message id
- content_key = message.message.id
- elif isinstance(message, UserTranscriptEntry):
- # For user messages, check for tool results
- if isinstance(message.message.content, list):
- for item in message.message.content:
- if isinstance(item, ToolResultContent):
- content_key = item.tool_use_id
- break
- # Fallback to uuid if no content key found
- if not content_key:
- content_key = getattr(message, "uuid", "")
-
- # Create deduplication key - include content_key for proper handling
- # of both version stutters and concurrent tool results
- dedup_key = (message_type, timestamp, is_meta, session_id, content_key)
-
- # Keep only first occurrence
- if dedup_key not in seen:
- seen.add(dedup_key)
- deduplicated.append(message)
-
- return deduplicated
-
-
-def generate_html(
- messages: List[TranscriptEntry],
- title: Optional[str] = None,
- combined_transcript_link: Optional[str] = None,
-) -> str:
- """Generate HTML from transcript messages using Jinja2 templates."""
- from .utils import get_warmup_session_ids
-
- # Performance timing
- t_start = time.time()
-
- with log_timing("Initialization", t_start):
- if not title:
- title = "Claude Transcript"
-
- # Filter out warmup-only sessions
- with log_timing("Filter warmup sessions", t_start):
- warmup_session_ids = get_warmup_session_ids(messages)
- if warmup_session_ids:
- messages = [
- msg
- for msg in messages
- if getattr(msg, "sessionId", None) not in warmup_session_ids
- ]
-
- # Pre-process to find and attach session summaries
- with log_timing("Session summary processing", t_start):
- session_summaries: Dict[str, str] = {}
- uuid_to_session: Dict[str, str] = {}
- uuid_to_session_backup: Dict[str, str] = {}
-
- # Build mapping from message UUID to session ID
- for message in messages:
- if hasattr(message, "uuid") and hasattr(message, "sessionId"):
- message_uuid = getattr(message, "uuid", "")
- session_id = getattr(message, "sessionId", "")
- if message_uuid and session_id:
- # There is often duplication, in that case we want to prioritise the assistant
- # message because summaries are generated from Claude's (last) success message
- if type(message) is AssistantTranscriptEntry:
- uuid_to_session[message_uuid] = session_id
- else:
- uuid_to_session_backup[message_uuid] = session_id
-
- # Map summaries to sessions via leafUuid -> message UUID -> session ID
- for message in messages:
- if isinstance(message, SummaryTranscriptEntry):
- leaf_uuid = message.leafUuid
- if leaf_uuid in uuid_to_session:
- session_summaries[uuid_to_session[leaf_uuid]] = message.summary
- elif (
- leaf_uuid in uuid_to_session_backup
- and uuid_to_session_backup[leaf_uuid] not in session_summaries
- ):
- session_summaries[uuid_to_session_backup[leaf_uuid]] = (
- message.summary
- )
-
- # Attach summaries to messages
- for message in messages:
- if hasattr(message, "sessionId"):
- session_id = getattr(message, "sessionId", "")
- if session_id in session_summaries:
- setattr(message, "_session_summary", session_summaries[session_id])
-
- # Process messages through the main rendering loop
- template_messages, sessions, session_order = _process_messages_loop(messages)
-
- # Prepare session navigation data
- session_nav: List[Dict[str, Any]] = []
- with log_timing(
- lambda: f"Session navigation building ({len(session_nav)} sessions)", t_start
- ):
- for session_id in session_order:
- session_info = sessions[session_id]
-
- # Skip empty sessions (agent-only, no user messages)
- if not session_info["first_user_message"]:
- continue
-
- # Format timestamp range
- first_ts = session_info["first_timestamp"]
- last_ts = session_info["last_timestamp"]
- timestamp_range = ""
- if first_ts and last_ts:
- if first_ts == last_ts:
- timestamp_range = format_timestamp(first_ts)
- else:
- timestamp_range = (
- f"{format_timestamp(first_ts)} - {format_timestamp(last_ts)}"
- )
- elif first_ts:
- timestamp_range = format_timestamp(first_ts)
-
- # Format token usage summary
- token_summary = ""
- total_input = session_info["total_input_tokens"]
- total_output = session_info["total_output_tokens"]
- total_cache_creation = session_info["total_cache_creation_tokens"]
- total_cache_read = session_info["total_cache_read_tokens"]
-
- if total_input > 0 or total_output > 0:
- token_parts: List[str] = []
- if total_input > 0:
- token_parts.append(f"Input: {total_input}")
- if total_output > 0:
- token_parts.append(f"Output: {total_output}")
- if total_cache_creation > 0:
- token_parts.append(f"Cache Creation: {total_cache_creation}")
- if total_cache_read > 0:
- token_parts.append(f"Cache Read: {total_cache_read}")
- token_summary = "Token usage – " + " | ".join(token_parts)
-
- session_nav.append(
- {
- "id": session_id,
- "summary": session_info["summary"],
- "timestamp_range": timestamp_range,
- "first_timestamp": first_ts,
- "last_timestamp": last_ts,
- "message_count": session_info["message_count"],
- "first_user_message": session_info["first_user_message"]
- if session_info["first_user_message"] != ""
- else "[No user message found in session.]",
- "token_summary": token_summary,
- }
- )
-
- # Reorder messages so each session's messages follow their session header
- # This fixes interleaving that occurs when sessions are resumed
- with log_timing("Reorder session messages", t_start):
- template_messages = _reorder_session_template_messages(template_messages)
-
- # Identify and mark paired messages (command+output, tool_use+tool_result, etc.)
- with log_timing("Identify message pairs", t_start):
- _identify_message_pairs(template_messages)
+ if message.message_id:
+ message_by_id[message.message_id] = message
- # Reorder messages so pairs are adjacent while preserving chronological order
- with log_timing("Reorder paired messages", t_start):
- template_messages = _reorder_paired_messages(template_messages)
+ # Clear any existing children (in case of re-processing)
+ for message in messages:
+ message.children = []
- # Reorder sidechains to appear after their Task results
- # This must happen AFTER pair reordering, since that moves tool_results
- with log_timing("Reorder sidechain messages", t_start):
- template_messages = _reorder_sidechain_template_messages(template_messages)
+ # Collect root messages (those with no ancestry)
+ root_messages: List[TemplateMessage] = []
- # Build hierarchy (message_id and ancestry) based on final order
- # This must happen AFTER all reordering to get correct parent-child relationships
- with log_timing("Build message hierarchy", t_start):
- _build_message_hierarchy(template_messages)
+ # Populate children based on ancestry
+ for message in messages:
+ if not message.ancestry:
+ # Root message (level 0, no parent)
+ root_messages.append(message)
+ else:
+ # Has a parent - add to parent's children
+ immediate_parent_id = message.ancestry[-1]
+ if immediate_parent_id in message_by_id:
+ parent = message_by_id[immediate_parent_id]
+ parent.children.append(message)
- # Mark messages that have children for fold/unfold controls
- with log_timing("Mark messages with children", t_start):
- _mark_messages_with_children(template_messages)
+ return root_messages
- # Render template
- with log_timing("Template environment setup", t_start):
- env = _get_template_environment()
- template = env.get_template("transcript.html")
-
- with log_timing(lambda: f"Template rendering ({len(html_output)} chars)", t_start):
- html_output = str(
- template.render(
- title=title,
- messages=template_messages,
- sessions=session_nav,
- combined_transcript_link=combined_transcript_link,
- library_version=get_library_version(),
- )
- )
- return html_output
+# -- Message Reordering -------------------------------------------------------
def _reorder_session_template_messages(
@@ -3329,7 +1585,7 @@ def _reorder_sidechain_template_messages(
sidechain_map: Dict[str, List[TemplateMessage]] = {}
for message in messages:
- is_sidechain = "sidechain" in message.css_class
+ is_sidechain = message.modifiers.is_sidechain
agent_id = message.agent_id
if is_sidechain and agent_id:
@@ -3357,7 +1613,14 @@ def _reorder_sidechain_template_messages(
# tool_use ever gets agent_id in the future
agent_id = message.agent_id
- if agent_id and message.type == "tool_result" and agent_id in sidechain_map:
+ # Only insert sidechain if not already inserted (handles case where
+ # multiple tool_results have the same agent_id)
+ if (
+ agent_id
+ and message.type == MessageType.TOOL_RESULT
+ and agent_id in sidechain_map
+ and agent_id not in used_agents
+ ):
sidechain_msgs = sidechain_map[agent_id]
# Deduplicate: find the last sidechain assistant with text content
@@ -3365,7 +1628,7 @@ def _reorder_sidechain_template_messages(
task_result_content = (
message.raw_text_content.strip() if message.raw_text_content else None
)
- if task_result_content and message.type == "tool_result":
+ if task_result_content and message.type == MessageType.TOOL_RESULT:
# Find the last assistant message in this sidechain
for sidechain_msg in reversed(sidechain_msgs):
sidechain_text = (
@@ -3374,69 +1637,263 @@ def _reorder_sidechain_template_messages(
else None
)
if (
- sidechain_msg.type == "assistant"
+ sidechain_msg.type == MessageType.ASSISTANT
and sidechain_text
and sidechain_text == task_result_content
):
# Replace with note pointing to the Task result
- forward_link_html = "(Task summary — already displayed in Task tool result above)
"
- sidechain_msg.content_html = forward_link_html
+ sidechain_msg.content = DedupNoticeContent(
+ notice_text="(Task summary — already displayed in Task tool result above)"
+ )
# Mark as deduplicated for potential debugging
sidechain_msg.raw_text_content = None
break
- # Insert the sidechain messages for this agent right after this message
- # Note: ancestry will be rebuilt by _build_message_hierarchy() later
- result.extend(sidechain_msgs)
- used_agents.add(agent_id)
+ # Insert the sidechain messages for this agent right after this message
+ # Note: ancestry will be rebuilt by _build_message_hierarchy() later
+ result.extend(sidechain_msgs)
+ used_agents.add(agent_id)
+
+ # Append any sidechains that weren't matched (shouldn't happen normally)
+ for agent_id, sidechain_msgs in sidechain_map.items():
+ if agent_id not in used_agents:
+ result.extend(sidechain_msgs)
+
+ return result
+
+
+def _filter_messages(messages: List[TranscriptEntry]) -> List[TranscriptEntry]:
+ """Filter messages to those that should be rendered.
+
+ This function filters out:
+ - Summary messages (already attached to sessions)
+ - Queue operations except 'remove' (steering messages)
+ - Messages with no meaningful content (no text and no tool items)
+ - Messages matching should_skip_message() (warmup, etc.)
+ - Sidechain user messages without tool results (prompts duplicate Task result)
+
+ System messages are included as they need special processing in _render_messages.
+
+ Args:
+ messages: List of transcript entries to filter
+
+ Returns:
+ Filtered list of messages that should be rendered
+ """
+ filtered: List[TranscriptEntry] = []
+
+ for message in messages:
+ message_type = message.type
+
+ # Skip summary messages
+ if isinstance(message, SummaryTranscriptEntry):
+ continue
+
+ # Skip most queue operations - only process 'remove' for counts
+ if isinstance(message, QueueOperationTranscriptEntry):
+ if message.operation != "remove":
+ continue
+
+ # System messages bypass other checks but are included
+ if isinstance(message, SystemTranscriptEntry):
+ filtered.append(message)
+ continue
+
+ # Get message content for filtering checks
+ if isinstance(message, QueueOperationTranscriptEntry):
+ message_content = message.content if message.content else []
+ else:
+ message_content = message.message.content # type: ignore
+
+ text_content = extract_text_content(message_content)
+
+ # Skip if no meaningful content
+ if not text_content.strip():
+ # Check for tool items
+ if isinstance(message_content, list):
+ has_tool_items = any(
+ isinstance(
+ item, (ToolUseContent, ToolResultContent, ThinkingContent)
+ )
+ or getattr(item, "type", None)
+ in ("tool_use", "tool_result", "thinking")
+ for item in message_content
+ )
+ if not has_tool_items:
+ continue
+ else:
+ continue
+
+ # Skip messages that should be filtered out
+ if should_skip_message(text_content):
+ continue
+
+ # Skip sidechain user messages that are just prompts (no tool results)
+ if message_type == MessageType.USER and getattr(message, "isSidechain", False):
+ if isinstance(message_content, list):
+ has_tool_results = any(
+ getattr(item, "type", None) == "tool_result"
+ or isinstance(item, ToolResultContent)
+ for item in message_content
+ )
+ if not has_tool_results:
+ continue
+
+ # Message passes all filters
+ filtered.append(message)
+
+ return filtered
+
+
+def _collect_session_info(
+ messages: List[TranscriptEntry],
+) -> tuple[
+ Dict[str, Dict[str, Any]], # sessions
+ List[str], # session_order
+ set[str], # show_tokens_for_message
+]:
+ """Collect session metadata and token tracking from pre-filtered messages.
+
+ This function iterates through messages to:
+ - Build session metadata (timestamps, message counts, first user message)
+ - Track token usage per session (deduplicating by requestId)
+ - Determine which messages should display token usage
+
+ Note: Messages should be pre-filtered by _filter_messages. System messages
+ in the input are skipped for session tracking purposes.
+
+ Args:
+ messages: Pre-filtered list of transcript entries
+
+ Returns:
+ Tuple containing:
+ - sessions: Session metadata dict mapping session_id to info
+ - session_order: List of session IDs in chronological order
+ - show_tokens_for_message: Set of message UUIDs that should display tokens
+ """
+ sessions: Dict[str, Dict[str, Any]] = {}
+ session_order: List[str] = []
+
+ # Track requestIds to avoid double-counting token usage
+ seen_request_ids: set[str] = set()
+ # Track which messages should show token usage (first occurrence of each requestId)
+ show_tokens_for_message: set[str] = set()
+
+ for message in messages:
+ # Skip system messages for session tracking
+ if isinstance(message, SystemTranscriptEntry):
+ continue
+
+ # Get message content
+ if isinstance(message, QueueOperationTranscriptEntry):
+ message_content = message.content if message.content else []
+ else:
+ message_content = message.message.content # type: ignore
+
+ text_content = extract_text_content(message_content) # type: ignore[arg-type]
+
+ # Get session info
+ session_id = getattr(message, "sessionId", "unknown")
+
+ # Initialize session if new
+ if session_id not in sessions:
+ current_session_summary = getattr(message, "_session_summary", None)
+
+ # Get first user message content for preview
+ first_user_message = ""
+ if is_user_entry(message) and should_use_as_session_starter(text_content):
+ content = extract_text_content(message.message.content)
+ first_user_message = create_session_preview(content)
+
+ sessions[session_id] = {
+ "id": session_id,
+ "summary": current_session_summary,
+ "first_timestamp": getattr(message, "timestamp", ""),
+ "last_timestamp": getattr(message, "timestamp", ""),
+ "message_count": 0,
+ "first_user_message": first_user_message,
+ "total_input_tokens": 0,
+ "total_output_tokens": 0,
+ "total_cache_creation_tokens": 0,
+ "total_cache_read_tokens": 0,
+ }
+ session_order.append(session_id)
+
+ # Update first user message if this is a user message and we don't have one yet
+ elif is_user_entry(message) and not sessions[session_id]["first_user_message"]:
+ first_user_content = extract_text_content(message.message.content)
+ if should_use_as_session_starter(first_user_content):
+ sessions[session_id]["first_user_message"] = create_session_preview(
+ first_user_content
+ )
+
+ sessions[session_id]["message_count"] += 1
- # Append any sidechains that weren't matched (shouldn't happen normally)
- for agent_id, sidechain_msgs in sidechain_map.items():
- if agent_id not in used_agents:
- result.extend(sidechain_msgs)
+ # Update last timestamp for this session
+ current_timestamp = getattr(message, "timestamp", "")
+ if current_timestamp:
+ sessions[session_id]["last_timestamp"] = current_timestamp
- return result
+ # Extract and accumulate token usage for assistant messages
+ # Only count tokens for the first message with each requestId to avoid duplicates
+ if is_assistant_entry(message):
+ assistant_message = message.message
+ request_id = message.requestId
+ message_uuid = message.uuid
+
+ if (
+ assistant_message.usage
+ and request_id
+ and request_id not in seen_request_ids
+ ):
+ # Mark this requestId as seen to avoid double-counting
+ seen_request_ids.add(request_id)
+ # Mark this specific message UUID as one that should show token usage
+ show_tokens_for_message.add(message_uuid)
+
+ usage = assistant_message.usage
+ sessions[session_id]["total_input_tokens"] += usage.input_tokens
+ sessions[session_id]["total_output_tokens"] += usage.output_tokens
+ if usage.cache_creation_input_tokens:
+ sessions[session_id]["total_cache_creation_tokens"] += (
+ usage.cache_creation_input_tokens
+ )
+ if usage.cache_read_input_tokens:
+ sessions[session_id]["total_cache_read_tokens"] += (
+ usage.cache_read_input_tokens
+ )
+
+ return sessions, session_order, show_tokens_for_message
-def _process_messages_loop(
+def _render_messages(
messages: List[TranscriptEntry],
-) -> tuple[
- List[TemplateMessage],
- Dict[str, Dict[str, Any]], # sessions
- List[str], # session_order
-]:
- """Process messages through the main rendering loop.
+ sessions: Dict[str, Dict[str, Any]],
+ show_tokens_for_message: set[str],
+) -> List[TemplateMessage]:
+ """Pass 2: Render pre-filtered messages to TemplateMessage objects.
- This function handles the core message processing logic:
- - Processes each message into template-friendly format
- - Tracks sessions and token usage
- - Handles message deduplication and hierarchy
+ This pass creates the actual TemplateMessage objects for rendering:
+ - Creates session headers when entering new sessions
+ - Processes text content into HTML
+ - Handles tool use, tool result, thinking, and image content
- Collects timing statistics
- Note: Tool use context must be built before calling this function via
- _define_tool_use_context()
+ Note: Messages are pre-filtered by _collect_session_info, so no additional
+ filtering is needed here except for system message processing.
Args:
- messages: List of transcript entries to process
+ messages: Pre-filtered list of transcript entries from _collect_session_info
+ sessions: Session metadata from _collect_session_info
+ show_tokens_for_message: Set of message UUIDs that should display tokens
Returns:
- Tuple containing:
- - template_messages: Processed messages ready for template rendering
- - sessions: Session metadata dict mapping session_id to info
- - session_order: List of session IDs in chronological order
+ List of TemplateMessage objects ready for template rendering
"""
- # Group messages by session and collect session info for navigation
- sessions: Dict[str, Dict[str, Any]] = {}
- session_order: List[str] = []
+ # Track which sessions have had headers added
seen_sessions: set[str] = set()
- # Track requestIds to avoid double-counting token usage
- seen_request_ids: set[str] = set()
- # 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 ToolUseContent for specialized tool result rendering
- # This will be populated inline as we encounter tool_use items during message processing
tool_use_context: Dict[str, ToolUseContent] = {}
# Process messages into template-friendly format
@@ -3464,169 +1921,54 @@ def _process_messages_loop(
# Update current message UUID for timing tracking
set_timing_var("_current_msg_uuid", msg_uuid)
- # NOTE: Sidechain user messages are handled below after content extraction
- # to distinguish prompts (skip) from tool results (render)
-
- # Skip summary messages - they should already be attached to their sessions
- if isinstance(message, SummaryTranscriptEntry):
- continue
-
- # Skip most queue operations - only render 'remove' as steering user messages
- if isinstance(message, QueueOperationTranscriptEntry):
- if message.operation != "remove":
- continue
- # 'remove' operations fall through to be rendered as user messages
-
- # Handle system messages separately
+ # Handle system messages separately (already filtered in pass 1)
if isinstance(message, SystemTranscriptEntry):
- session_id = getattr(message, "sessionId", "unknown")
- timestamp = getattr(message, "timestamp", "")
- formatted_timestamp = format_timestamp(timestamp) if timestamp else ""
-
- # Handle hook summaries (subtype="stop_hook_summary")
- if message.subtype == "stop_hook_summary":
- # Skip silent hook successes (no output, no errors)
- if not message.hasOutput and not message.hookErrors:
- continue
- # Render hook summary with collapsible details
- content_html = _render_hook_summary(message)
- level_css = "system system-hook"
- level = "hook"
- elif not message.content:
- # Skip system messages without content (shouldn't happen normally)
- continue
- else:
- # Extract command name if present
- command_name_match = re.search(
- r"(.*?) ", message.content, re.DOTALL
- )
- # Also check for command output (child of user command)
- command_output_match = re.search(
- r"(.*?) ",
- message.content,
- re.DOTALL,
- )
-
- # Create level-specific styling and icons
- level = getattr(message, "level", "info")
- level_icon = {"warning": "⚠️", "error": "❌", "info": "ℹ️"}.get(
- level, "ℹ️"
- )
-
- # Determine CSS class:
- # - Command name (user-initiated): "system" only
- # - Command output (assistant response): "system system-{level}"
- # - Other system messages: "system system-{level}"
- if command_name_match:
- # User-initiated command
- level_css = "system"
- else:
- # Command output or other system message
- level_css = f"system system-{level}"
-
- # Process content: extract command name or command output, or use full content
- if command_name_match:
- # Show just the command name
- command_name = command_name_match.group(1).strip()
- html_content = f"{html.escape(command_name)}"
- content_html = f"{level_icon} {html_content}"
- elif command_output_match:
- # Extract and process command output
- output = command_output_match.group(1).strip()
- html_content = _convert_ansi_to_html(output)
- content_html = f"{level_icon} {html_content}"
- else:
- # 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}"
-
- # Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy)
- parent_uuid = getattr(message, "parentUuid", None)
-
- system_template_message = TemplateMessage(
- 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()}",
- message_id=None, # Will be assigned by _build_message_hierarchy
- ancestry=[], # Will be assigned by _build_message_hierarchy
- uuid=message.uuid,
- parent_uuid=parent_uuid,
- )
- template_messages.append(system_template_message)
+ system_template_message = _process_system_message(message)
+ if system_template_message:
+ template_messages.append(system_template_message)
continue
# Handle queue-operation 'remove' messages as user messages
if isinstance(message, QueueOperationTranscriptEntry):
- # Queue operations have content directly, not in message.message
message_content = message.content if message.content else []
- # Treat as user message type
- message_type = "queue-operation"
+ message_type = MessageType.QUEUE_OPERATION
else:
- # Extract message content first to check for duplicates
- # Must be UserTranscriptEntry or AssistantTranscriptEntry
message_content = message.message.content # type: ignore
- text_content = extract_text_content(message_content)
+ text_content = extract_text_content(message_content) # type: ignore[arg-type]
# Separate tool/thinking/image content from text content
- # Images in user messages stay inline, images in assistant messages are separate
tool_items: List[ContentItem] = []
text_only_content: List[ContentItem] = []
if isinstance(message_content, list):
text_only_items: List[ContentItem] = []
- for item in message_content:
- # Check for both custom types and Anthropic types
- item_type = getattr(item, "type", None)
+ for item in message_content: # type: ignore[union-attr]
+ item_type = getattr(item, "type", None) # type: ignore[arg-type]
is_image = isinstance(item, ImageContent) or item_type == "image"
is_tool_item = isinstance(
item,
(ToolUseContent, ToolResultContent, ThinkingContent),
) or item_type in ("tool_use", "tool_result", "thinking")
- # Keep images inline for user messages and queue operations (steering),
- # extract for assistant messages
if is_image and (
- message_type == "user"
+ message_type == MessageType.USER
or isinstance(message, QueueOperationTranscriptEntry)
):
- text_only_items.append(item)
+ text_only_items.append(item) # type: ignore[arg-type]
elif is_tool_item or is_image:
- tool_items.append(item)
+ tool_items.append(item) # type: ignore[arg-type]
else:
- text_only_items.append(item)
+ text_only_items.append(item) # type: ignore[arg-type]
text_only_content = text_only_items
else:
- # Single string content
- message_content = message_content.strip()
+ message_content = message_content.strip() # type: ignore[union-attr]
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:
- continue
-
- # Skip messages that should be filtered out
- if should_skip_message(text_content):
- continue
+ text_only_content = [TextContent(type="text", text=message_content)] # type: ignore[arg-type]
- # Skip sidechain user messages that are just prompts (no tool results)
- # Sidechain prompts duplicate the Task tool input and are redundant,
- # but tool results from sidechain agents should be rendered
- if message_type == "user" and getattr(message, "isSidechain", False):
- has_tool_results = any(
- getattr(item, "type", None) == "tool_result"
- or isinstance(item, ToolResultContent)
- for item in tool_items
- )
- if not has_tool_results:
- continue
- # For sidechain user messages with tool results, clear text content
- # to avoid rendering the redundant prompt text
+ # For sidechain user messages with tool results, clear text content
+ # (prompts duplicate Task result; filtering already done in pass 1)
+ if message_type == MessageType.USER and getattr(message, "isSidechain", False):
text_only_content = []
text_content = ""
@@ -3636,130 +1978,50 @@ def _process_messages_loop(
is_bash_cmd = is_bash_input(text_content)
is_bash_result = is_bash_output(text_content)
- # Check if we're in a new session
+ # Get session info
session_id = getattr(message, "sessionId", "unknown")
session_summary = getattr(message, "_session_summary", None)
- # Track sessions for navigation and add session header if new
- if session_id not in sessions:
- # Get the session summary for this session (may be None)
- current_session_summary = getattr(message, "_session_summary", None)
-
- # Get first user message content for preview
- first_user_message = ""
- if (
- message_type == "user"
- and not isinstance(message, QueueOperationTranscriptEntry)
- and hasattr(message, "message")
- and should_use_as_session_starter(text_content)
- ):
- content = extract_text_content(message.message.content)
- first_user_message = create_session_preview(content)
-
- sessions[session_id] = {
- "id": session_id,
- "summary": current_session_summary,
- "first_timestamp": getattr(message, "timestamp", ""),
- "last_timestamp": getattr(message, "timestamp", ""),
- "message_count": 0,
- "first_user_message": first_user_message,
- "total_input_tokens": 0,
- "total_output_tokens": 0,
- "total_cache_creation_tokens": 0,
- "total_cache_read_tokens": 0,
- }
- session_order.append(session_id)
-
- # Add session header message
- if session_id not in seen_sessions:
- seen_sessions.add(session_id)
- # Create a meaningful session title
- session_title = (
- f"{current_session_summary} • {session_id[:8]}"
- if current_session_summary
- else session_id[:8]
- )
+ # Add session header if this is a new session
+ if session_id not in seen_sessions:
+ seen_sessions.add(session_id)
+ current_session_summary = sessions.get(session_id, {}).get("summary")
+ session_title = (
+ f"{current_session_summary} • {session_id[:8]}"
+ if current_session_summary
+ else session_id[:8]
+ )
- session_header = TemplateMessage(
- message_type="session_header",
- content_html=session_title,
- formatted_timestamp="",
- css_class="session-header",
- raw_timestamp=None,
- session_summary=current_session_summary,
+ session_header = TemplateMessage(
+ message_type="session_header",
+ formatted_timestamp="",
+ raw_timestamp=None,
+ session_summary=current_session_summary,
+ session_id=session_id,
+ is_session_header=True,
+ message_id=None,
+ ancestry=[],
+ modifiers=MessageModifiers(),
+ content=SessionHeaderContent(
+ title=session_title,
session_id=session_id,
- is_session_header=True,
- message_id=None, # Will be assigned by _build_message_hierarchy
- ancestry=[], # Session headers are top-level
- )
- template_messages.append(session_header)
-
- # Update first user message if this is a user message and we don't have one yet
- elif message_type == "user" and not sessions[session_id]["first_user_message"]:
- if not isinstance(message, QueueOperationTranscriptEntry) and hasattr(
- message, "message"
- ):
- first_user_content = extract_text_content(message.message.content)
- if should_use_as_session_starter(first_user_content):
- sessions[session_id]["first_user_message"] = create_session_preview(
- first_user_content
- )
-
- sessions[session_id]["message_count"] += 1
-
- # Update last timestamp for this session
- current_timestamp = getattr(message, "timestamp", "")
- if current_timestamp:
- sessions[session_id]["last_timestamp"] = current_timestamp
-
- # Extract and accumulate token usage for assistant messages
- # Only count tokens for the first message with each requestId to avoid duplicates
- if message_type == "assistant" and hasattr(message, "message"):
- assistant_message = getattr(message, "message")
- request_id = getattr(message, "requestId", None)
- message_uuid = getattr(message, "uuid", "")
-
- if (
- hasattr(assistant_message, "usage")
- and assistant_message.usage
- and request_id
- and request_id not in seen_request_ids
- ):
- # Mark this requestId as seen to avoid double-counting
- seen_request_ids.add(request_id)
- # Mark this specific message UUID as one that should show token usage
- show_tokens_for_message.add(message_uuid)
-
- usage = assistant_message.usage
- sessions[session_id]["total_input_tokens"] += usage.input_tokens
- sessions[session_id]["total_output_tokens"] += usage.output_tokens
- if usage.cache_creation_input_tokens:
- sessions[session_id]["total_cache_creation_tokens"] += (
- usage.cache_creation_input_tokens
- )
- if usage.cache_read_input_tokens:
- sessions[session_id]["total_cache_read_tokens"] += (
- usage.cache_read_input_tokens
- )
+ summary=current_session_summary,
+ ),
+ )
+ template_messages.append(session_header)
# Get timestamp (only for non-summary messages)
- timestamp = (
- getattr(message, "timestamp", "") if hasattr(message, "timestamp") else ""
- )
+ timestamp = getattr(message, "timestamp", "")
formatted_timestamp = format_timestamp(timestamp) if timestamp else ""
# Extract token usage for assistant messages
# Only show token usage for the first message with each requestId to avoid duplicates
token_usage_str: Optional[str] = None
- if message_type == "assistant" and hasattr(message, "message"):
- assistant_message = getattr(message, "message")
- message_uuid = getattr(message, "uuid", "")
+ if is_assistant_entry(message):
+ assistant_message = message.message
+ message_uuid = message.uuid
- if (
- hasattr(assistant_message, "usage")
- and assistant_message.usage
- and message_uuid in show_tokens_for_message
- ):
+ if assistant_message.usage and message_uuid in show_tokens_for_message:
# Only show token usage for messages marked as first occurrence of requestId
usage = assistant_message.usage
token_parts = [
@@ -3774,22 +2036,24 @@ def _process_messages_loop(
token_parts.append(f"Cache Read: {usage.cache_read_input_tokens}")
token_usage_str = " | ".join(token_parts)
- # Determine CSS class and content based on message type and duplicate status
+ # Determine modifiers and content based on message type
+ content_model: Optional[MessageContent] = None
+
if is_command:
- css_class, content_html, message_type, message_title = (
+ modifiers, content_model, message_type, message_title = (
_process_command_message(text_content)
)
elif is_local_output:
- css_class, content_html, message_type, message_title = (
+ modifiers, content_model, message_type, message_title = (
_process_local_command_output(text_content)
)
elif is_bash_cmd:
- css_class, content_html, message_type, message_title = _process_bash_input(
+ modifiers, content_model, message_type, message_title = _process_bash_input(
text_content
)
elif is_bash_result:
- css_class, content_html, message_type, message_title = _process_bash_output(
- text_content
+ modifiers, content_model, message_type, message_title = (
+ _process_bash_output(text_content)
)
else:
# For queue-operation messages, treat them as user messages
@@ -3798,7 +2062,7 @@ def _process_messages_loop(
else:
effective_type = message_type
- css_class, content_html, message_type_result, message_title = (
+ modifiers, content_model, message_type_result, message_title = (
_process_regular_message(
text_only_content,
effective_type,
@@ -3808,12 +2072,12 @@ def _process_messages_loop(
)
message_type = message_type_result # Update message_type with result
- # Add 'steering' CSS class for queue-operation 'remove' messages
+ # Add 'steering' modifier for queue-operation 'remove' messages
if (
isinstance(message, QueueOperationTranscriptEntry)
and message.operation == "remove"
):
- css_class = f"{css_class} steering"
+ modifiers = replace(modifiers, is_steering=True)
message_title = "User (steering)"
# Only create main message if it has text content
@@ -3822,9 +2086,7 @@ def _process_messages_loop(
if text_only_content:
template_message = TemplateMessage(
message_type=message_type,
- content_html=content_html,
formatted_timestamp=formatted_timestamp,
- css_class=css_class,
raw_timestamp=timestamp,
session_summary=session_summary,
session_id=session_id,
@@ -3835,6 +2097,8 @@ def _process_messages_loop(
agent_id=getattr(message, "agentId", None),
uuid=getattr(message, "uuid", None),
parent_uuid=getattr(message, "parentUuid", None),
+ modifiers=modifiers,
+ content=content_model,
)
# Store raw text content for potential future use (e.g., deduplication,
@@ -3852,216 +2116,67 @@ def _process_messages_loop(
# 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
- pending_dedup: Optional[str] = (
- None # Holds task result content for deduplication
- )
+ # Dispatch to appropriate handler based on item type
+ tool_result: Optional[ToolItemResult] = None
if isinstance(tool_item, ToolUseContent) or item_type == "tool_use":
- # Convert Anthropic type to our format if necessary
- if not isinstance(tool_item, ToolUseContent):
- tool_use = ToolUseContent(
- type="tool_use",
- id=getattr(tool_item, "id", ""),
- name=getattr(tool_item, "name", ""),
- input=getattr(tool_item, "input", {}),
- )
- else:
- tool_use = tool_item
-
- tool_content_html = format_tool_use_content(tool_use)
- escaped_name = escape_html(tool_use.name)
- escaped_id = escape_html(tool_use.id)
- item_tool_use_id = tool_use.id
- tool_title_hint = f"ID: {escaped_id}"
-
- # Populate tool_use_context for later use when processing tool results
- tool_use_context[item_tool_use_id] = tool_use
-
- # Get summary for header (description or filepath)
- summary = get_tool_summary(tool_use)
-
- # Set message_type (for CSS/logic) and message_title (for display)
- tool_message_type = "tool_use"
- if tool_use.name == "TodoWrite":
- tool_message_title = "📝 Todo List"
- elif tool_use.name == "Task":
- # Special handling for Task tool: show subagent_type and description
- subagent_type = tool_use.input.get("subagent_type", "")
- description = tool_use.input.get("description", "")
- escaped_subagent = (
- escape_html(subagent_type) if subagent_type else ""
- )
-
- if description and subagent_type:
- escaped_desc = escape_html(description)
- tool_message_title = f"🔧 {escaped_name} {escaped_desc} ({escaped_subagent})"
- elif description:
- escaped_desc = escape_html(description)
- tool_message_title = f"🔧 {escaped_name} {escaped_desc}"
- elif subagent_type:
- tool_message_title = f"🔧 {escaped_name} ({escaped_subagent})"
- else:
- tool_message_title = f"🔧 {escaped_name}"
- elif tool_use.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.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_title = escaped_name
- tool_css_class = "tool_use"
+ tool_result = _process_tool_use_item(tool_item, tool_use_context)
elif isinstance(tool_item, ToolResultContent) or item_type == "tool_result":
- # Convert Anthropic type to our format if necessary
- if not isinstance(tool_item, ToolResultContent):
- tool_result_converted = ToolResultContent(
- type="tool_result",
- tool_use_id=getattr(tool_item, "tool_use_id", ""),
- content=getattr(tool_item, "content", ""),
- is_error=getattr(tool_item, "is_error", False),
- )
- else:
- tool_result_converted = tool_item
-
- # 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_use_from_ctx = tool_use_context[
- tool_result_converted.tool_use_id
- ]
- result_tool_name = tool_use_from_ctx.name
- if (
- result_tool_name
- in (
- "Read",
- "Edit",
- "Write",
- )
- and "file_path" in tool_use_from_ctx.input
- ):
- result_file_path = tool_use_from_ctx.input["file_path"]
-
- tool_content_html = format_tool_result_content(
- tool_result_converted,
- result_file_path,
- result_tool_name,
- )
-
- # Retroactive deduplication: if Task result matches a sidechain assistant, replace that assistant with a forward link
- if result_tool_name == "Task":
- # Extract text content from tool result
- # Note: tool_result.content can be str or List[Dict[str, Any]] (not List[ContentItem])
- if isinstance(tool_result_converted.content, str):
- task_result_content = tool_result_converted.content.strip()
- else:
- # Handle list of dicts (tool result format)
- content_parts: list[str] = []
- for item in tool_result_converted.content:
- # tool_result_converted.content is List[Dict[str, Any]]
- text_val = item.get("text", "")
- if isinstance(text_val, str):
- content_parts.append(text_val)
- task_result_content = "\n".join(content_parts).strip()
-
- # Store for deduplication - we'll check/update after we have the message_id
- pending_dedup = task_result_content if task_result_content else None
- else:
- pending_dedup = None
-
- 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, icon is set by template
- 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
- else "tool_result"
- )
+ tool_result = _process_tool_result_item(tool_item, tool_use_context)
elif isinstance(tool_item, ThinkingContent) or item_type == "thinking":
- # Convert Anthropic type to our format if necessary
- if not isinstance(tool_item, ThinkingContent):
- thinking_converted = ThinkingContent(
- type="thinking",
- thinking=getattr(tool_item, "thinking", str(tool_item)),
- )
- else:
- thinking_converted = tool_item
-
- tool_content_html = format_thinking_content(thinking_converted)
- tool_message_type = "thinking"
- tool_message_title = "Thinking"
- tool_css_class = "thinking"
+ tool_result = _process_thinking_item(tool_item)
elif isinstance(tool_item, ImageContent) or item_type == "image":
- # Convert Anthropic type to our format if necessary
- if not isinstance(tool_item, ImageContent):
- # For now, skip Anthropic image types - we'll handle when we encounter them
- continue
- else:
- tool_content_html = format_image_content(tool_item)
- tool_message_type = "image"
- tool_message_title = "Image"
- tool_css_class = "image"
+ tool_result = _process_image_item(tool_item)
else:
# Handle unknown content types
- tool_content_html = (
- f"Unknown content type: {escape_html(str(type(tool_item)))}
"
+ tool_result = ToolItemResult(
+ message_type="unknown",
+ content=UnknownContent(type_name=str(type(tool_item))),
+ message_title="Unknown Content",
)
- tool_message_type = "unknown"
- tool_message_title = "Unknown Content"
- tool_css_class = "unknown"
+
+ # Skip if handler returned None (e.g., unsupported image types)
+ if tool_result is None:
+ continue
# Preserve sidechain context for tool/thinking/image content within sidechain messages
tool_is_sidechain = getattr(message, "isSidechain", False)
- if tool_is_sidechain:
- tool_css_class += " sidechain"
+
+ # Build modifiers directly from tool_result properties
+ tool_modifiers = MessageModifiers(
+ is_sidechain=tool_is_sidechain,
+ is_error=tool_result.is_error,
+ )
# Generate unique UUID for this tool message
# Use tool_use_id if available, otherwise fall back to message UUID + index
tool_uuid = (
- item_tool_use_id
- if item_tool_use_id
+ tool_result.tool_use_id
+ if tool_result.tool_use_id
else f"{msg_uuid}-tool-{len(template_messages)}"
)
tool_template_message = TemplateMessage(
- message_type=tool_message_type,
- content_html=tool_content_html,
+ message_type=tool_result.message_type,
formatted_timestamp=tool_formatted_timestamp,
- css_class=tool_css_class,
raw_timestamp=tool_timestamp,
session_summary=session_summary,
session_id=session_id,
- tool_use_id=item_tool_use_id,
- title_hint=tool_title_hint,
- message_title=tool_message_title,
+ tool_use_id=tool_result.tool_use_id,
+ title_hint=tool_result.title_hint,
+ message_title=tool_result.message_title,
message_id=None, # Will be assigned by _build_message_hierarchy
ancestry=[], # Will be assigned by _build_message_hierarchy
agent_id=getattr(message, "agentId", None),
uuid=tool_uuid,
+ modifiers=tool_modifiers,
+ content=tool_result.content, # Structured content model
)
# Store raw text for Task result deduplication
# (handled later in _reorder_sidechain_template_messages)
- if pending_dedup is not None:
- tool_template_message.raw_text_content = pending_dedup
- pending_dedup = None
+ if tool_result.pending_dedup is not None:
+ tool_template_message.raw_text_content = tool_result.pending_dedup
template_messages.append(tool_template_message)
@@ -4077,21 +2192,55 @@ def _process_messages_loop(
[("Markdown", markdown_timings), ("Pygments", pygments_timings)],
)
- return (
- template_messages,
- sessions,
- session_order,
+ return template_messages
+
+
+# -- Project Index Generation -------------------------------------------------
+
+
+def prepare_projects_index(
+ project_summaries: List[Dict[str, Any]],
+) -> tuple[List["TemplateProject"], "TemplateSummary"]:
+ """Prepare project data for rendering in any format.
+
+ Args:
+ project_summaries: List of project summary dictionaries.
+
+ Returns:
+ A tuple of (template_projects, template_summary) for use by renderers.
+ """
+ # Sort projects by last modified (most recent first)
+ sorted_projects = sorted(
+ project_summaries, key=lambda p: p["last_modified"], reverse=True
)
+ # Convert to template-friendly format
+ template_projects = [TemplateProject(project) for project in sorted_projects]
+ template_summary = TemplateSummary(project_summaries)
+
+ return template_projects, template_summary
+
-def generate_projects_index_html(
+def title_for_projects_index(
project_summaries: List[Dict[str, Any]],
from_date: Optional[str] = None,
to_date: Optional[str] = None,
) -> str:
- """Generate an index HTML page listing all projects using Jinja2 templates."""
- # Try to get a better title from working directories in projects
+ """Generate a title for the projects index page.
+
+ Determines a meaningful title based on working directories from projects,
+ with optional date range suffix.
+
+ Args:
+ project_summaries: List of project summary dictionaries.
+ from_date: Optional start date filter string.
+ to_date: Optional end date filter string.
+
+ Returns:
+ A title string for the projects index page.
+ """
title = "Claude Code Projects"
+
if project_summaries:
# Collect all working directories from all projects
all_working_dirs: set[str] = set()
@@ -4134,6 +2283,8 @@ def generate_projects_index_html(
except Exception:
# Fall back to default title if path analysis fails
pass
+
+ # Add date range suffix if provided
if from_date or to_date:
date_range_parts: List[str] = []
if from_date:
@@ -4143,23 +2294,77 @@ def generate_projects_index_html(
date_range_str = " ".join(date_range_parts)
title += f" ({date_range_str})"
- # Sort projects by last modified (most recent first)
- sorted_projects = sorted(
- project_summaries, key=lambda p: p["last_modified"], reverse=True
- )
+ return title
- # Convert to template-friendly format
- template_projects = [TemplateProject(project) for project in sorted_projects]
- template_summary = TemplateSummary(project_summaries)
- # Render template
- env = _get_template_environment()
- template = env.get_template("index.html")
- return str(
- template.render(
- title=title,
- projects=template_projects,
- summary=template_summary,
- library_version=get_library_version(),
- )
- )
+# -- Renderer Classes ---------------------------------------------------------
+
+
+class Renderer:
+ """Base class for transcript renderers.
+
+ Subclasses implement format-specific rendering (HTML, Markdown, etc.).
+ """
+
+ def generate(
+ self,
+ messages: List[TranscriptEntry],
+ title: Optional[str] = None,
+ combined_transcript_link: Optional[str] = None,
+ ) -> Optional[str]:
+ """Generate output from transcript messages.
+
+ Returns None by default; subclasses override to return formatted output.
+ """
+ return None
+
+ def generate_session(
+ self,
+ messages: List[TranscriptEntry],
+ session_id: str,
+ title: Optional[str] = None,
+ cache_manager: Optional["CacheManager"] = None,
+ ) -> Optional[str]:
+ """Generate output for a single session.
+
+ Returns None by default; subclasses override to return formatted output.
+ """
+ return None
+
+ def generate_projects_index(
+ self,
+ project_summaries: List[Dict[str, Any]],
+ from_date: Optional[str] = None,
+ to_date: Optional[str] = None,
+ ) -> Optional[str]:
+ """Generate a projects index page.
+
+ Returns None by default; subclasses override to return formatted output.
+ """
+ return None
+
+ def is_outdated(self, file_path: Path) -> Optional[bool]:
+ """Check if a rendered file is outdated.
+
+ Returns None by default; subclasses override to return True/False.
+ """
+ return None
+
+
+def get_renderer(format: str) -> Renderer:
+ """Get a renderer instance for the specified format.
+
+ Args:
+ format: The output format (currently only "html" is supported).
+
+ Returns:
+ A Renderer instance for the specified format.
+
+ Raises:
+ ValueError: If the format is not supported.
+ """
+ if format == "html":
+ from .html.renderer import HtmlRenderer
+
+ return HtmlRenderer()
+ raise ValueError(f"Unsupported format: {format}")
diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py
index d7f080f1..861a9db0 100644
--- a/claude_code_log/tui.py
+++ b/claude_code_log/tui.py
@@ -21,7 +21,7 @@
from .cache import CacheManager, SessionCacheData, get_library_version
from .converter import ensure_fresh_cache
-from .renderer import get_project_display_name
+from .utils import get_project_display_name
class ProjectSelector(App[Path]):
diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py
index b146fc3c..82f3a733 100644
--- a/claude_code_log/utils.py
+++ b/claude_code_log/utils.py
@@ -2,41 +2,92 @@
"""Utility functions for message filtering and processing."""
import re
-from typing import Dict, List, Union
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, List, Optional, Union
from claude_code_log.cache import SessionCacheData
from .models import ContentItem, TextContent, TranscriptEntry, UserTranscriptEntry
+from .parser import (
+ IDE_DIAGNOSTICS_PATTERN,
+ IDE_OPENED_FILE_PATTERN,
+ IDE_SELECTION_PATTERN,
+ is_command_message,
+ is_local_command_output,
+ is_system_message,
+)
-def is_system_message(text_content: str) -> bool:
- """Check if a message is a system message that should be filtered out."""
- system_message_patterns = [
- "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.",
- "[Request interrupted by user for tool use]",
- "",
- ]
-
- return any(text_content.startswith(pattern) for pattern in system_message_patterns)
-
-
-def is_command_message(text_content: str) -> bool:
- """Check if a message contains command information that should be displayed."""
- return "" in text_content and "" in text_content
+def format_timestamp(timestamp_str: str | None) -> str:
+ """Format ISO timestamp for display, converting to UTC."""
+ if timestamp_str is None:
+ return ""
+ try:
+ dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
+ # Convert to UTC if timezone-aware
+ if dt.tzinfo is not None:
+ utc_timetuple = dt.utctimetuple()
+ dt = datetime(
+ utc_timetuple.tm_year,
+ utc_timetuple.tm_mon,
+ utc_timetuple.tm_mday,
+ utc_timetuple.tm_hour,
+ utc_timetuple.tm_min,
+ utc_timetuple.tm_sec,
+ )
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
+ except (ValueError, AttributeError):
+ return timestamp_str
+
+
+def format_timestamp_range(first_timestamp: str, last_timestamp: str) -> str:
+ """Format timestamp range for display.
+ Args:
+ first_timestamp: ISO timestamp for range start
+ last_timestamp: ISO timestamp for range end
-def is_local_command_output(text_content: str) -> bool:
- """Check if a message contains local command output."""
- return "" in text_content
+ Returns:
+ Formatted string like "2025-01-01 10:00:00 - 2025-01-01 11:00:00"
+ or single timestamp if both are equal, or empty string if neither provided.
+ """
+ if first_timestamp and last_timestamp:
+ if first_timestamp == last_timestamp:
+ return format_timestamp(first_timestamp)
+ else:
+ return f"{format_timestamp(first_timestamp)} - {format_timestamp(last_timestamp)}"
+ elif first_timestamp:
+ return format_timestamp(first_timestamp)
+ else:
+ return ""
-def is_bash_input(text_content: str) -> bool:
- """Check if a message contains bash input command."""
- return "" in text_content and " " in text_content
+def get_project_display_name(
+ project_dir_name: str, working_directories: Optional[List[str]] = None
+) -> str:
+ """Get the display name for a project based on working directories.
+ Args:
+ project_dir_name: The Claude project directory name (e.g., "-Users-dain-workspace-claude-code-log")
+ working_directories: List of working directories from cache data
-def is_bash_output(text_content: str) -> bool:
- """Check if a message contains bash command output."""
- return "" in text_content or "" in text_content
+ Returns:
+ The project display name (e.g., "claude-code-log")
+ """
+ if working_directories:
+ # Convert to Path objects with their original indices for tracking recency
+ paths_with_indices = [(Path(wd), i) for i, wd in enumerate(working_directories)]
+
+ # Sort by: 1) path depth (fewer parts = less nested), 2) recency (lower index = more recent)
+ # This gives us the least nested path, with ties broken by recency
+ best_path, _ = min(paths_with_indices, key=lambda p: (len(p[0].parts), p[1]))
+ return best_path.name
+ else:
+ # Fall back to converting project directory name
+ display_name = project_dir_name
+ if display_name.startswith("-"):
+ display_name = display_name[1:].replace("-", "/")
+ return display_name
def should_skip_message(text_content: str) -> bool:
@@ -163,17 +214,6 @@ def extract_working_directories(
return [path for path, _ in sorted_dirs]
-# IDE tag patterns for compact preview rendering (same as renderer.py)
-IDE_OPENED_FILE_PATTERN = re.compile(
- r"(.*?) ", re.DOTALL
-)
-IDE_SELECTION_PATTERN = re.compile(r"(.*?) ", re.DOTALL)
-IDE_DIAGNOSTICS_PATTERN = re.compile(
- r"\s*(.*?) \s* ",
- re.DOTALL,
-)
-
-
def _compact_ide_tags_for_preview(text_content: str) -> str:
"""Replace verbose IDE/system tags with compact emoji indicators for previews.
@@ -212,57 +252,52 @@ def _extract_file_path(content: str) -> str | None:
# Process only LEADING IDE tags - stop when we hit non-IDE content
# This prevents replacing tags inside quoted strings/JSONL content
+ # Uses shared patterns from parser.py for consistency
compact_parts: list[str] = []
remaining = text_content
+ # Compiled pattern for bash-input (not in parser.py as it's preview-specific)
+ bash_input_pattern = re.compile(r"(.*?) ", re.DOTALL)
+
while remaining:
- # Try to match each IDE tag type at the start of remaining text
- # Check for at start
- match = re.match(
- r"^\s*(.*?) ", remaining, re.DOTALL
- )
+ # Strip leading whitespace for matching
+ stripped = remaining.lstrip()
+
+ # Try to match each IDE tag type at the start of stripped text
+ # Check for at start (using shared pattern)
+ match = IDE_OPENED_FILE_PATTERN.match(stripped)
if match:
content = match.group(1).strip()
filepath = _extract_file_path(content)
compact_parts.append(f"📎 {filepath}" if filepath else "📎 file")
- remaining = remaining[match.end() :]
+ remaining = stripped[match.end() :]
continue
- # Check for at start
- match = re.match(
- r"^\s*(.*?) ", remaining, re.DOTALL
- )
+ # Check for at start (using shared pattern)
+ match = IDE_SELECTION_PATTERN.match(stripped)
if match:
content = match.group(1).strip()
filepath = _extract_file_path(content)
compact_parts.append(f"✂️ {filepath}" if filepath else "✂️ selection")
- remaining = remaining[match.end() :]
+ remaining = stripped[match.end() :]
continue
- # Check for ... at start
- match = re.match(
- r"^\s*\s*.*? \s* ",
- remaining,
- re.DOTALL,
- )
+ # Check for ... (using shared pattern)
+ match = IDE_DIAGNOSTICS_PATTERN.match(stripped)
if match:
compact_parts.append("🩺 diagnostics")
- remaining = remaining[match.end() :]
+ remaining = stripped[match.end() :]
continue
# Check for command at start
- match = re.match(
- r"^\s*(.*?) ",
- remaining,
- re.DOTALL,
- )
+ match = bash_input_pattern.match(stripped)
if match:
command = match.group(1).strip()
# Truncate very long commands
if len(command) > 50:
command = command[:47] + "..."
compact_parts.append(f"💻 {command}")
- remaining = remaining[match.end() :]
+ remaining = stripped[match.end() :]
continue
# No more tags at start - stop processing
@@ -279,40 +314,6 @@ def _extract_file_path(content: str) -> str | None:
return text_content
-def is_warmup_only_session(messages: List[TranscriptEntry], session_id: str) -> bool:
- """Check if a session contains only warmup user messages.
-
- A warmup session is one where ALL user messages are literally just "Warmup".
- Sessions with no user messages return False (not considered warmup).
-
- Args:
- messages: List of all transcript entries
- session_id: The session ID to check
-
- Returns:
- True if ALL user messages in the session are "Warmup", False otherwise
- """
- from .parser import extract_text_content
-
- user_messages_in_session: List[str] = []
-
- for message in messages:
- if (
- isinstance(message, UserTranscriptEntry)
- and getattr(message, "sessionId", "") == session_id
- and hasattr(message, "message")
- ):
- text_content = extract_text_content(message.message.content).strip()
- user_messages_in_session.append(text_content)
-
- # No user messages = not a warmup session
- if not user_messages_in_session:
- return False
-
- # All user messages must be exactly "Warmup"
- return all(msg == "Warmup" for msg in user_messages_in_session)
-
-
def get_warmup_session_ids(messages: List[TranscriptEntry]) -> set[str]:
"""Get set of session IDs that are warmup-only sessions.
diff --git a/dev-docs/FOLD_STATE_DIAGRAM.md b/dev-docs/FOLD_STATE_DIAGRAM.md
index fd3df528..a8af2c2d 100644
--- a/dev-docs/FOLD_STATE_DIAGRAM.md
+++ b/dev-docs/FOLD_STATE_DIAGRAM.md
@@ -7,8 +7,9 @@ The virtual parent/child structure of a conversation determines how folding work
```
Session (level 0)
└── User message (level 1)
- ├── System: Info (level 2)
+ ├── System: command/error (level 2)
└── Assistant response (level 2)
+ ├── System: info/warning (level 3)
├── Tool: Read ─────────────┐ (level 3)
│ └── Tool result ────────┘ paired, fold together
└── Tool: Task ─────────────┐ (level 3)
@@ -128,3 +129,150 @@ Fold buttons display context-aware tooltips showing what will happen on click (n
- **Performance**: Descendant counting is O(n) using cached hierarchy lookups
- **Paired messages**: Pairs are counted as single units in child/descendant counts
- **Labels**: Fold bars show type-aware labels like "3 assistant, 4 tools" or "2 tool pairs"
+
+---
+
+## Hierarchy System Architecture
+
+The hierarchy system in `renderer.py` determines message nesting for the fold/unfold UI.
+It consists of three main functions:
+
+### `_get_message_hierarchy_level(css_class, is_sidechain) -> int`
+
+Determines the hierarchy level for a message based on its CSS class and sidechain status.
+
+**Level Definitions:**
+
+| Level | Message Types | Description |
+|-------|---------------|-------------|
+| 0 | `session-header` | Session dividers |
+| 1 | `user` | User messages (top-level conversation) |
+| 2 | `assistant`, `thinking`, `system` (commands/errors) | Direct responses to user |
+| 3 | `tool_use`, `tool_result`, `system-info`, `system-warning` | Nested under assistant |
+| 4 | `assistant sidechain`, `thinking sidechain` | Sub-agent responses (from Task tool) |
+| 5 | `tool_use sidechain`, `tool_result sidechain` | Sub-agent tools |
+
+**Decision Logic:**
+
+```
+css_class contains? is_sidechain? Result
+──────────────────── ────────────── ──────
+"user" false Level 1
+"system-info/warning" false Level 3
+"system" false Level 2
+"assistant/thinking" true Level 4
+"tool" true Level 5
+"assistant/thinking" false Level 2
+"tool" false Level 3
+(default) - Level 1
+```
+
+**Edge Cases:**
+- Sidechain user messages are skipped entirely (they duplicate Task tool input)
+- `system-info` and `system-warning` are at level 3 (tool-related notifications)
+- `system` (commands/errors) without info/warning are at level 2
+
+### `_build_message_hierarchy(messages) -> None`
+
+Builds `message_id` and `ancestry` for all messages using a stack-based approach.
+
+**Algorithm:**
+
+1. Maintain a stack of `(level, message_id)` tuples
+2. For each message:
+ - Determine level via `_get_message_hierarchy_level()`
+ - Pop stack until finding appropriate parent (level < current)
+ - Build ancestry from remaining stack entries
+ - Push current message onto stack
+3. Session headers use `session-{uuid}` format for navigation
+4. Other messages use `d-{counter}` format
+
+**Ancestry Example:**
+
+```
+Session (session-abc) ancestry: []
+└── User (d-0) ancestry: ["session-abc"]
+ └── Assistant (d-1) ancestry: ["session-abc", "d-0"]
+ └── Tool use (d-2) ancestry: ["session-abc", "d-0", "d-1"]
+ └── Tool result (d-3) ancestry: ["session-abc", "d-0", "d-1", "d-2"]
+```
+
+**Important:** This function must be called after all reordering operations (pair reordering,
+sidechain reordering) to ensure hierarchy reflects final display order.
+
+### `_mark_messages_with_children(messages) -> None`
+
+Calculates descendant counts for fold bar labels.
+
+**Computed Fields:**
+
+| Field | Description |
+|-------|-------------|
+| `has_children` | True if message has any children |
+| `immediate_children_count` | Count of direct children only |
+| `total_descendants_count` | Count of all descendants recursively |
+| `immediate_children_by_type` | Dict mapping css_class to count |
+| `total_descendants_by_type` | Dict mapping css_class to count |
+
+**Algorithm:**
+
+1. Build O(1) lookup index of messages by ID
+2. For each message with ancestry:
+ - Skip `pair_last` messages (pairs count as one unit)
+ - Increment immediate parent's `immediate_children_count`
+ - Increment all ancestors' `total_descendants_count`
+ - Track counts by message type for detailed labels
+
+**Time Complexity:** O(n) where n is message count
+
+### JavaScript Fold Controls Interaction
+
+The JavaScript in `templates/components/fold_bar.html` uses these computed values:
+
+1. **Ancestry classes**: Each message has `d-{n}` classes from ancestry for CSS targeting
+2. **Child counts**: Displayed in fold bar buttons ("▶ 3 messages")
+3. **Descendant counts**: Displayed in fold-all button ("▶▶ 125 total")
+4. **Type counts**: Used for descriptive labels ("2 assistant, 4 tools")
+
+**Visibility Control:**
+
+```javascript
+// Toggle immediate children visibility
+document.querySelectorAll(`.d-${messageId}`).forEach(child => {
+ child.classList.toggle('filtered-hidden');
+});
+
+// Toggle all descendants visibility
+ancestry.forEach(ancestorId => {
+ document.querySelectorAll(`.d-${ancestorId}`).forEach(child => {
+ child.classList.toggle('filtered-hidden');
+ });
+});
+```
+
+### Sidechain (Sub-agent) Handling
+
+Messages from Task tool sub-agents are handled specially:
+
+1. **Identification**: `isSidechain: true` in JSONL → `sidechain` in css_class
+2. **Level assignment**: Sidechain assistant/thinking at level 4, tools at level 5
+3. **Reordering**: Sidechain messages appear under their Task tool result
+4. **Skipping**: Sidechain user messages are skipped (duplicate Task input)
+5. **Deduplication**: Identical sidechain results are replaced with links
+
+### Paired Message Handling
+
+Paired messages (tool_use + tool_result, thinking + assistant) are handled as units:
+
+1. **Pairing**: `_identify_message_pairs()` links messages via `tool_use_id`
+2. **Counting**: Only `pair_first` messages count toward parent's children
+3. **Folding**: Both messages fold/unfold together
+4. **Display**: Pair duration shown on `pair_last` message
+
+---
+
+## References
+
+- [renderer.py](../claude_code_log/renderer.py) - Hierarchy functions (lines 2698-2850)
+- [templates/components/fold_bar.html](../claude_code_log/templates/components/fold_bar.html) - JavaScript controls
+- [css-classes.md](css-classes.md) - CSS class documentation
diff --git a/dev-docs/MESSAGE_REFACTORING.md b/dev-docs/MESSAGE_REFACTORING.md
new file mode 100755
index 00000000..230f5856
--- /dev/null
+++ b/dev-docs/MESSAGE_REFACTORING.md
@@ -0,0 +1,520 @@
+# Message Rendering Refactoring Plan
+
+This document tracks the ongoing refactoring effort to improve the message rendering code in `renderer.py`.
+
+## Current State (dev/message-tree-refactoring)
+
+As of December 2025, significant refactoring has been completed. The architecture now separates format-neutral message processing from HTML-specific rendering:
+
+| Module | Lines | Notes |
+|--------|-------|-------|
+| `renderer.py` | 2525 | Format-neutral: tree building, pairing, hierarchy |
+| `html/renderer.py` | 297 | HtmlRenderer: tree traversal, template rendering |
+| `html/tool_formatters.py` | 950 | Tool use/result HTML formatting |
+| `html/user_formatters.py` | 326 | User message HTML formatting |
+| `html/assistant_formatters.py` | 90 | Assistant/thinking HTML formatting |
+| `html/system_formatters.py` | 113 | System message HTML formatting |
+| `html/utils.py` | 352 | Shared HTML utilities (markdown, escape, etc.) |
+| `html/ansi_colors.py` | 261 | ANSI → HTML conversion |
+| `models.py` | 858 | Content models, MessageModifiers |
+
+**Key architectural changes:**
+- **Tree-first architecture** - `generate_template_messages()` returns tree roots, HtmlRenderer flattens via pre-order traversal
+- **Format-neutral Renderer base class** - Subclasses (HtmlRenderer) implement format-specific rendering
+- **Content models in models.py** - SessionHeaderContent, DedupNoticeContent, IdeNotificationContent, etc.
+- **Formatter separation** - HTML formatters split by message type in `html/` directory
+
+## Motivation
+
+The refactoring aims to:
+
+1. **Improve maintainability** - Functions are too large (some 600+ lines)
+2. **Better separation of concerns** - Move specialized utilities to dedicated modules
+3. **Improve type safety** - Use typed objects instead of generic dictionaries
+4. **Enable testing** - Large functions are difficult to unit test
+5. **Performance profiling** - Timing instrumentation to identify bottlenecks
+
+## Related Refactoring Branches
+
+### dev/message-tree-refactoring (Current Branch)
+
+This branch implements tree-based message rendering. See [TEMPLATE_MESSAGE_CHILDREN.md](TEMPLATE_MESSAGE_CHILDREN.md) for details.
+
+**Completed Work:**
+- ✅ Phase 1: Added `children: List[TemplateMessage]` field to TemplateMessage
+- ✅ Phase 1: Added `flatten()` and `flatten_all()` methods for backward compatibility
+- ✅ Phase 2: Implemented `_build_message_tree()` function
+- ✅ **Phase 2.5: Tree-first architecture** (December 2025)
+ - `generate_template_messages()` now returns tree roots, not flat list
+ - `HtmlRenderer._flatten_preorder()` traverses tree, formats content, builds flat list
+ - Content formatting happens during pre-order traversal (single pass)
+ - Template unchanged - still receives flat list (Phase 3 future work)
+
+**Architecture:**
+```
+TranscriptEntry[] → generate_template_messages() → root_messages (tree)
+ ↓
+ HtmlRenderer._flatten_preorder() → flat_list
+ ↓
+ template.render(messages=flat_list)
+```
+
+**Integration with this refactoring:**
+- Tree structure enables future **recursive template rendering** (Phase 3 in TEMPLATE_MESSAGE_CHILDREN.md)
+- Provides foundation for **Visitor pattern** output formats (HTML, Markdown, JSON)
+- Format-neutral `Renderer` base class allows alternative renderer implementations
+
+### golergka's text-output-format Branch (ada7ef5)
+
+Adds text/markdown/chat output formats via new `content_extractor.py` module.
+
+**Key Changes:**
+- Created `content_extractor.py` with dataclasses: `ExtractedText`, `ExtractedThinking`, `ExtractedToolUse`, `ExtractedToolResult`, `ExtractedImage`
+- Refactored `render_message_content()` to use extraction layer (~70 lines changed)
+- Added `text_renderer.py` for text-based output (426 lines)
+- CLI `--format` option: html, text, markdown, chat
+
+**Relationship to This Refactoring:**
+
+| Aspect | golergka's Approach | This Refactoring |
+|--------|---------------------|------------------|
+| Focus | Multi-format output | Code organization |
+| Data layer | ContentItem → ExtractedContent | TemplateMessage tree |
+| Presentation | Separate renderers per format | Modular HTML renderer |
+| Compatibility | Parallel to HTML | Refactor existing HTML |
+
+**Integration Assessment:**
+- **Complementary**: golergka's extraction layer operates at ContentItem level, this refactoring at TemplateMessage level
+- **Low conflict**: `content_extractor.py` is a new module, doesn't touch hierarchy/pairing code
+- **Synergy opportunity**: Text renderer could benefit from tree structure for nested output
+- **Risk**: `render_message_content()` changes in golergka's PR conflict with local changes
+
+**Recommendation:** Consider integrating golergka's work **after** completing Phase 3 (ANSI extraction) and Phase 4 (Tool formatters extraction). The content extraction layer is useful for multi-format support, but is tangential to the core refactoring goals of reducing renderer.py complexity.
+
+## Completed Phases
+
+### Phase 1: Timing Infrastructure (Commits: 56b2807, 8426f39)
+
+**Goal**: Centralize timing utilities and standardize timing instrumentation patterns
+
+**Changes**:
+- ✅ Extracted timing utilities to `renderer_timings.py` module
+- ✅ Moved `DEBUG_TIMING` environment variable handling to timing module
+- ✅ Standardized `log_timing` context manager pattern - work goes INSIDE the `with` block
+- ✅ Added support for dynamic phase names using lambda expressions
+- ✅ Removed top-level `os` import from renderer.py (no longer needed)
+
+**Benefits**:
+- All timing-related code centralized in one module
+- Consistent timing instrumentation throughout renderer
+- Easy to enable/disable timing with `CLAUDE_CODE_LOG_DEBUG_TIMING` environment variable
+- Better insight into rendering performance
+
+### Phase 2: Tool Use Context Optimization (Commit: 56b2807)
+
+**Goal**: Simplify tool use context management and eliminate unnecessary pre-processing
+
+**Analysis**:
+- `tool_use_context` was only used when processing tool results
+- The "prompt" member stored for Task tools wasn't actually used in lookups
+- Tool uses always appear before tool results chronologically
+- No need for separate pre-processing pass
+
+**Changes**:
+- ✅ Removed `_define_tool_use_context()` function (68 lines eliminated)
+- ✅ Changed `tool_use_context` from `Dict[str, Dict[str, Any]]` to `Dict[str, ToolUseContent]`
+- ✅ Build index inline when creating ToolUseContent objects during message processing
+- ✅ Use attribute access instead of dictionary access for better type safety
+- ✅ Replaced dead code in `render_message_content` with warnings
+
+**Benefits**:
+- Eliminated entire pre-processing pass through messages
+- Better type safety with ToolUseContent objects
+- Cleaner code with inline index building
+- ~70 lines of code removed
+
+### Phase 3: ANSI Color Module Extraction ✅ COMPLETE
+
+**Goal**: Extract ANSI color conversion to dedicated module
+
+**Changes**:
+- ✅ Created `claude_code_log/ansi_colors.py` (261 lines)
+- ✅ Moved `_convert_ansi_to_html()` → `convert_ansi_to_html()`
+- ✅ Updated imports in `renderer.py`
+- ✅ Updated test imports in `test_ansi_colors.py`
+
+**Result**: 242 lines removed from renderer.py (4246 → 4004)
+
+### Phase 4: Code Rendering Module Extraction ✅ COMPLETE
+
+**Goal**: Extract code-related rendering (Pygments highlighting, diff rendering) to dedicated module
+
+**Changes**:
+- ✅ Created `claude_code_log/renderer_code.py` (330 lines)
+- ✅ Moved `_highlight_code_with_pygments()` → `highlight_code_with_pygments()`
+- ✅ Moved `_truncate_highlighted_preview()` → `truncate_highlighted_preview()`
+- ✅ Moved `_render_single_diff()` → `render_single_diff()`
+- ✅ Moved `_render_line_diff()` → `render_line_diff()`
+- ✅ Updated imports in `renderer.py`
+- ✅ Updated test imports in `test_preview_truncation.py`
+- ✅ Removed unused Pygments imports from renderer.py
+
+**Result**: 274 lines removed from renderer.py (4004 → 3730)
+
+**Note**: The original Phase 4 plan targeted tool formatters (~600 lines), but due to tight coupling with `escape_html`, `render_markdown`, and other utilities, we extracted a cleaner subset: code highlighting and diff rendering. The remaining tool formatters could be extracted in a future phase once the shared utilities are better factored.
+
+### Phase 5: Message Processing Decomposition ✅ PARTIAL
+
+**Goal**: Break down the 687-line `_process_messages_loop()` into smaller functions
+
+**Changes**:
+- ✅ Created `_process_system_message()` function (~88 lines) - handles hook summaries, commands, system messages
+- ✅ Created `ToolItemResult` dataclass for structured tool processing results
+- ✅ Created `_process_tool_use_item()` function (~84 lines) - handles tool_use content items
+- ✅ Created `_process_tool_result_item()` function (~71 lines) - handles tool_result content items
+- ✅ Created `_process_thinking_item()` function (~21 lines) - handles thinking content
+- ✅ Created `_process_image_item()` function (~17 lines) - handles image content
+- ✅ Replaced ~220 lines of nested conditionals with clean dispatcher pattern
+
+**Result**: `_process_messages_loop()` reduced from ~687 to ~460 lines (33% smaller)
+
+**Note**: File size increased slightly (3730 → 3814 lines) due to new helper functions, but the main loop is now much more maintainable with focused, testable helper functions. Further decomposition (session tracking, token usage extraction) could reduce it to ~200 lines but would require more complex parameter passing.
+
+### Phase 6: Message Pairing Simplification ✅ COMPLETE
+
+**Goal**: Simplify the complex pairing logic in `_identify_message_pairs()`
+
+**Changes**:
+- ✅ Created `PairingIndices` dataclass to hold all lookup indices in one place
+- ✅ Extracted `_build_pairing_indices()` function (~35 lines) - builds all indices in single pass
+- ✅ Extracted `_mark_pair()` utility (~8 lines) - marks first/last message pairing
+- ✅ Extracted `_try_pair_adjacent()` function (~25 lines) - handles adjacent message pairs
+- ✅ Extracted `_try_pair_by_index()` function (~30 lines) - handles index-based pairing
+- ✅ Simplified `_identify_message_pairs()` from ~120 lines to ~37 lines (69% smaller)
+
+**Result**: Pairing logic decomposed into focused helpers with clear responsibilities:
+- `_build_pairing_indices()`: O(n) index building for tool_use, tool_result, uuid, slash_command lookups
+- `_try_pair_adjacent()`: Handles system+slash, command+output, tool_use+result adjacent pairs
+- `_try_pair_by_index()`: Handles index-based pairing for non-adjacent messages
+
+**Note**: File size increased slightly (3814 → 3853 lines) due to new helper functions, but the main pairing function is now much cleaner and each helper is independently testable.
+
+## Planned Future Phases
+
+### Phase 7: Message Type Documentation ✅ COMPLETE
+
+**Goal**: Document message types and CSS classes comprehensively
+
+**Completed Work**:
+- ✅ Created comprehensive [css-classes.md](css-classes.md) with:
+ - Complete CSS class combinations (19 semantic patterns)
+ - CSS rule support status (24 full, 7 partial, 1 none)
+ - Pairing behavior documentation (pair_first/pair_last rules)
+ - Fold-bar support analysis
+- ✅ Updated [messages.md](messages.md) with:
+ - Complete css_class trait mapping table
+ - Pairing patterns and rules by type
+ - Full tool table (16 tools with model info)
+ - Cross-references to css-classes.md
+
+### Phase 8: Testing Infrastructure ✅ COMPLETE
+
+**Goal**: Improve test coverage for refactored modules
+
+**Completed Work**:
+- ✅ Created `test/test_phase8_message_variants.py` with tests for:
+ - Slash command rendering (`isMeta=True` flag)
+ - Queue operations skip behavior (enqueue/dequeue not rendered)
+ - CSS class modifiers composition (`error`, `sidechain`, combinations)
+ - Deduplication with modifiers
+- ✅ Created `test/test_renderer.py` with edge case tests for:
+ - System message handling
+ - Write and Edit tool rendering
+- ✅ Created `test/test_renderer_code.py` with tests for:
+ - Pygments highlighting (pattern matching, unknown extensions, ClassNotFound)
+ - Truncated highlighted preview
+ - Diff rendering edge cases (consecutive removals, hint line skipping)
+- ✅ Simplified CSS by removing redundant `paired-message` class
+- ✅ Updated snapshot tests and documentation
+
+**Test Files Added**:
+- [test/test_phase8_message_variants.py](../test/test_phase8_message_variants.py) - Message type variants
+- [test/test_renderer.py](../test/test_renderer.py) - Renderer edge cases
+- [test/test_renderer_code.py](../test/test_renderer_code.py) - Code highlighting/diff tests
+
+**Coverage Notes**:
+- Some lines in `renderer_code.py` (116-118, 319) are unreachable due to algorithm behavior
+- Pygments `ClassNotFound` exception path covered via mock testing
+
+### Phase 9: Type Safety Improvements ✅ COMPLETE
+
+**Goal**: Replace string-based type checking with enums and typed structures
+
+**Completed Work**:
+- ✅ Added `MessageType(str, Enum)` in `models.py` with all message types
+- ✅ Added type guards for TranscriptEntry union narrowing (available for future use)
+- ✅ Updated `renderer.py` to use `MessageType` enum for key comparisons
+- ✅ Maintained backward compatibility via `str` base class
+
+**MessageType Enum Values**:
+- JSONL entry types: `USER`, `ASSISTANT`, `SYSTEM`, `SUMMARY`, `QUEUE_OPERATION`
+- Rendering types: `TOOL_USE`, `TOOL_RESULT`, `THINKING`, `IMAGE`, `BASH_INPUT`, `BASH_OUTPUT`, `SESSION_HEADER`, `UNKNOWN`
+- System subtypes: `SYSTEM_INFO`, `SYSTEM_WARNING`, `SYSTEM_ERROR`
+
+**Type Guards Added**:
+- `is_user_entry()`, `is_assistant_entry()`, `is_system_entry()`, `is_summary_entry()`, `is_queue_operation_entry()`
+- `is_tool_use_content()`, `is_tool_result_content()`, `is_thinking_content()`, `is_image_content()`, `is_text_content()`
+
+**Note**: MessageModifiers dataclass deferred - existing boolean flags work well for now
+
+### Phase 10: Parser Simplification ✅ COMPLETE
+
+**Goal**: Simplify `extract_text_content()` using isinstance checks
+
+**Completed Work**:
+- ✅ Added imports for Anthropic SDK types: `TextBlock`, `ThinkingBlock`
+- ✅ Simplified `extract_text_content()` with clean isinstance checks
+- ✅ Removed defensive `hasattr`/`getattr` patterns
+- ✅ 23% code reduction (17 lines → 13 lines)
+
+**Before** (defensive pattern):
+```python
+if hasattr(item, "type") and getattr(item, "type") == "text":
+ text = getattr(item, "text", "")
+ if text:
+ text_parts.append(text)
+```
+
+**After** (clean isinstance):
+```python
+if isinstance(item, (TextContent, TextBlock)):
+ text_parts.append(item.text)
+elif isinstance(item, (ThinkingContent, ThinkingBlock)):
+ continue
+```
+
+**Testing Evidence**: All 431 tests pass with simplified version
+**Risk**: Low - maintains same behavior, fully tested
+
+### Phase 11: Tool Model Enhancement ✅ COMPLETE
+
+**Goal**: Add typed models for tool inputs (currently all generic `Dict[str, Any]`)
+
+**Completed Work**:
+- ✅ Added 9 typed input models to `models.py`:
+ - `BashInput`, `ReadInput`, `WriteInput`, `EditInput`, `MultiEditInput`
+ - `GlobInput`, `GrepInput`, `TaskInput`, `TodoWriteInput`
+- ✅ Created `ToolInput` union type for type-safe tool input handling
+- ✅ Added `TOOL_INPUT_MODELS` mapping for tool name → model class lookup
+- ✅ Added `parse_tool_input()` helper function with fallback to raw dict
+
+**Typed Input Models Added**:
+```python
+class BashInput(BaseModel):
+ command: str
+ description: Optional[str] = None
+ timeout: Optional[int] = None
+ run_in_background: Optional[bool] = None
+ dangerouslyDisableSandbox: Optional[bool] = None
+
+class ReadInput(BaseModel):
+ file_path: str
+ offset: Optional[int] = None
+ limit: Optional[int] = None
+
+class EditInput(BaseModel):
+ file_path: str
+ old_string: str
+ new_string: str
+ replace_all: Optional[bool] = None
+```
+
+**Note**: The `ToolUseContent.input` field remains `Dict[str, Any]` for backward compatibility.
+The new typed models are available for optional use via `parse_tool_input()`. Existing
+code continues to work unchanged with dictionary access.
+
+**Independence from Phase 12**: Phase 11 and Phase 12 are independent improvements.
+Phase 12 focuses on architectural decomposition (splitting renderer.py into format-neutral
+and format-specific modules), while Phase 11 provides typed tool input models as an
+optional type-safety enhancement. The typed models can be adopted incrementally by any
+code that wants to use them, independent of the format-neutral refactoring.
+
+### Phase 12: Renderer Decomposition - Format Neutral ✅ COMPLETE
+
+**Goal**: Separate format-neutral logic from HTML-specific generation
+
+**Achieved Architecture** (December 2025):
+```
+renderer.py (2525 lines) - Format-neutral
+├── generate_template_messages() → returns tree roots
+├── Renderer base class (subclassed by HtmlRenderer)
+├── TemplateMessage, TemplateProject, TemplateSummary classes
+├── Message processing loop with content model creation
+├── Pairing & hierarchy logic
+└── Deduplication
+
+html/ directory - HTML-specific
+├── renderer.py (297 lines) - HtmlRenderer class
+│ ├── _flatten_preorder() - tree traversal + formatting
+│ ├── _format_message_content() - dispatches to formatters
+│ └── generate(), generate_session() - template rendering
+├── tool_formatters.py (950 lines) - Tool use/result formatters
+├── user_formatters.py (326 lines) - User message formatters
+├── assistant_formatters.py (90 lines) - Assistant/thinking formatters
+├── system_formatters.py (113 lines) - System message formatters
+├── utils.py (352 lines) - Markdown, escape, collapsibles
+└── ansi_colors.py (261 lines) - ANSI → HTML conversion
+
+models.py (858 lines) - Content models
+├── MessageContent base class and subclasses
+├── SessionHeaderContent, DedupNoticeContent (renderer content)
+├── IdeNotificationContent, UserTextContent (user content)
+├── ReadOutput, EditOutput, etc. (tool output models)
+└── MessageModifiers dataclass
+```
+
+**Implementation Steps** (completed differently than original plan):
+
+| Step | Description | Status |
+|------|-------------|--------|
+| 1-5 | Initial HTML extraction | ✅ Complete |
+| 6 | Split tool formatters (two-stage: parse + render) | ✅ Done via content models in models.py |
+| 7 | Split message content renderers | ✅ Done via html/{user,assistant,system,tool}_formatters.py |
+| 8 | Split _process_* message functions | ✅ Content models created during processing |
+| 9 | Move generate_projects_index_html | ⏸️ Still in renderer.py (format-neutral prep + HTML) |
+| 10-11 | Final organization | ✅ Complete |
+
+**Steps 6-8 Resolution**:
+The original plan called for two-stage (parse + render) splits. This was achieved differently:
+- **Content models** in `models.py` capture parsed data (SessionHeaderContent, IdeNotificationContent, ReadOutput, etc.)
+- **Format-neutral processing** in `renderer.py` creates content models during message processing
+- **HTML formatters** in `html/*.py` render content models to HTML
+- **Tree-first architecture** means HtmlRenderer traverses tree and formats during pre-order walk
+
+**Step 9 Status**:
+`generate_projects_index_html()` remains in renderer.py because:
+- Mixes format-neutral data preparation (TemplateProject/TemplateSummary) with HTML generation
+- Moving just the HTML part would require restructuring the data flow
+- Low priority: function works correctly and is ~100 lines
+
+**Dependencies**:
+- Requires Phase 9 (type safety) for clean interfaces ✅
+- Benefits from Phase 10 (parser simplification) ✅
+- Tree-first architecture (TEMPLATE_MESSAGE_CHILDREN.md Phase 2.5) ✅
+- Enables golergka's multi-format integration
+
+**Risk**: High - requires careful refactoring
+**Status**: ✅ COMPLETE
+
+## Recommended Execution Order
+
+For maximum impact with minimum risk:
+
+### Completed
+1. ✅ **Phase 3 (ANSI)** - Low risk, self-contained, immediate ~250 line reduction
+2. ✅ **Phase 4 (Code rendering)** - Medium risk, ~274 line reduction, clear boundaries
+3. ✅ **Phase 5 (Processing)** - High impact, main loop 33% smaller
+4. ✅ **Phase 6 (Pairing)** - Pairing function 69% smaller, clear helpers
+5. ✅ **Phase 7 (Documentation)** - Complete CSS/message docs
+6. ✅ **Phase 8 (Testing)** - Coverage gap tests, message variant tests, CSS simplification
+7. ✅ **Phase 9 (Type Safety)** - MessageType enum and type guards added
+
+### Next Steps
+8. ✅ **Phase 10 (Parser)** - Simplified extract_text_content() with isinstance checks
+9. ✅ **Phase 11 (Tool Models)** - Added typed input models for 9 common tools
+10. ✅ **Phase 12 (Format Neutral)** - HTML formatters in `html/` directory, content models in models.py
+11. ✅ **Tree-first architecture** - `generate_template_messages()` returns tree roots (TEMPLATE_MESSAGE_CHILDREN.md Phase 2.5)
+
+**Current Status (December 2025):**
+- All planned phases complete
+- renderer.py reduced from 4246 to 2525 lines (41% reduction)
+- Clean separation: format-neutral in renderer.py, HTML-specific in html/ directory
+- Tree-first architecture enables future recursive template rendering
+
+**Future Work:**
+- **Recursive templates** (TEMPLATE_MESSAGE_CHILDREN.md Phase 3): Pass tree roots directly to template with recursive macro
+- **Alternative renderers**: Text/markdown renderer using Renderer base class
+- **golergka integration**: Content models and tree structure ready for multi-format output
+
+## Metrics to Track
+
+| Metric | Baseline (v0.9) | Current (Dec 2025) | Target |
+|--------|-----------------|-------------------|--------|
+| renderer.py lines | 4246 | 2525 | ✅ <3000 |
+| html/ directory | - | 2389 total | - |
+| models.py lines | ~400 | 858 | - |
+| Largest function | ~687 lines | ~300 lines | <100 lines |
+| `_identify_message_pairs()` | ~120 lines | ~37 lines | ✅ |
+| Typed tool input models | 0 | 9 | ✅ |
+| Content models | 0 | 15+ | - |
+| Module count | 3 | 11 | - |
+| Test coverage | ~78% | ~78% | >85% |
+
+**html/ directory breakdown:**
+- renderer.py: 297 lines (HtmlRenderer)
+- tool_formatters.py: 950 lines
+- user_formatters.py: 326 lines
+- utils.py: 352 lines
+- ansi_colors.py: 261 lines
+- assistant_formatters.py: 90 lines
+- system_formatters.py: 113 lines
+
+**Progress Summary**:
+- renderer.py reduced by 41% (4246 → 2525 lines)
+- Format-neutral/HTML separation complete
+- Tree-first architecture implemented
+- Content models moved to models.py
+- HTML formatters organized by message type in html/ directory
+
+## Quality Gates
+
+Before merging any phase:
+
+- [ ] `just test-all` passes
+- [ ] `uv run pyright` passes with 0 errors
+- [ ] `ruff check` passes
+- [ ] Snapshot tests unchanged (or intentionally updated)
+- [ ] No performance regression (check with `CLAUDE_CODE_LOG_DEBUG_TIMING=1`)
+
+## Notes
+
+- All changes should maintain backward compatibility
+- Each phase should be committed separately for easy review
+- Consider feature flags for large changes during development
+- Run against real Claude projects to verify visual correctness
+
+## References
+
+### Code Modules - Format Neutral
+- [renderer.py](../claude_code_log/renderer.py) - Format-neutral rendering (2525 lines)
+- [models.py](../claude_code_log/models.py) - Content models, MessageModifiers, type guards (858 lines)
+- [renderer_code.py](../claude_code_log/renderer_code.py) - Code highlighting & diffs (330 lines)
+- [renderer_timings.py](../claude_code_log/renderer_timings.py) - Timing utilities
+- [parser.py](../claude_code_log/parser.py) - JSONL parsing
+
+### Code Modules - HTML Specific (html/ directory)
+- [html/renderer.py](../claude_code_log/html/renderer.py) - HtmlRenderer class (297 lines)
+- [html/tool_formatters.py](../claude_code_log/html/tool_formatters.py) - Tool HTML formatters (950 lines)
+- [html/user_formatters.py](../claude_code_log/html/user_formatters.py) - User message formatters (326 lines)
+- [html/assistant_formatters.py](../claude_code_log/html/assistant_formatters.py) - Assistant/thinking formatters (90 lines)
+- [html/system_formatters.py](../claude_code_log/html/system_formatters.py) - System message formatters (113 lines)
+- [html/utils.py](../claude_code_log/html/utils.py) - Markdown, escape, collapsibles (352 lines)
+- [html/ansi_colors.py](../claude_code_log/html/ansi_colors.py) - ANSI color conversion (261 lines)
+
+### Documentation
+- [css-classes.md](css-classes.md) - Complete CSS class reference with support status
+- [messages.md](messages.md) - Message types, content models, tool documentation
+- [FOLD_STATE_DIAGRAM.md](FOLD_STATE_DIAGRAM.md) - Fold system documentation
+- [TEMPLATE_MESSAGE_CHILDREN.md](TEMPLATE_MESSAGE_CHILDREN.md) - Tree architecture (Phase 2.5 complete)
+
+### Tests
+- [test/test_ansi_colors.py](../test/test_ansi_colors.py) - ANSI tests
+- [test/test_preview_truncation.py](../test/test_preview_truncation.py) - Code preview tests
+- [test/test_sidechain_agents.py](../test/test_sidechain_agents.py) - Integration tests
+- [test/test_template_data.py](../test/test_template_data.py) - Tree building tests (TestTemplateMessageTree)
+- [test/test_phase8_message_variants.py](../test/test_phase8_message_variants.py) - Message variants
+- [test/test_renderer.py](../test/test_renderer.py) - Renderer edge cases
+- [test/test_renderer_code.py](../test/test_renderer_code.py) - Code highlighting/diff tests
+
+### External
+- golergka's branch: `remotes/golergka/feat/text-output-format` (commit ada7ef5)
diff --git a/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md
new file mode 100644
index 00000000..b1acf510
--- /dev/null
+++ b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md
@@ -0,0 +1,137 @@
+# Template Message Children Architecture
+
+This document tracks the exploration of a children-based architecture for `TemplateMessage`, where messages can have nested children to form an explicit tree structure.
+
+## Current Architecture (2025-12-13)
+
+### Data Flow
+```
+TranscriptEntry[] → generate_template_messages() → root_messages (tree)
+ ↓
+ HtmlRenderer._flatten_preorder() → flat_list
+ ↓
+ template.render(messages=flat_list)
+```
+
+### TemplateMessage (current)
+- `generate_template_messages()` returns **tree roots** (typically session headers)
+- Each message has `children: List[TemplateMessage]` populated
+- `ancestry` field preserved for CSS classes / JavaScript fold/unfold
+- HtmlRenderer flattens via pre-order traversal before template rendering
+
+### Hierarchy Levels
+```
+Level 0: Tree roots (messages without ancestry - typically session headers)
+Level 1: User messages
+Level 2: Assistant, System, Thinking
+Level 3: Tool use/result
+Level 4: Sidechain assistant/thinking
+Level 5: Sidechain tools
+```
+
+**Note:** Tree roots are any messages with empty `ancestry`. This is typically session headers, but in degenerate cases (no session headers), user messages or other top-level messages become roots.
+
+### Template Rendering (current)
+- Single `{% for message in messages %}` loop over flattened list
+- Ancestry rendered as CSS classes for JavaScript DOM queries
+- Fold/unfold uses `document.querySelectorAll('.message.${targetId}')`
+- Tree structure used internally but template still receives flat list
+
+## Future: Recursive Template Rendering
+
+The next step would be to pass tree roots directly to the template and use a recursive macro, eliminating the flatten step.
+
+### Template Rendering (future)
+Recursive macro approach (Note: html_content is now passed separately, not stored in message):
+```jinja2
+{% macro render_message(message, html_content, depth=0) %}
+
+ {{ html_content | safe }}
+ {% if message.children %}
+
+ {% for child, child_html in message.children_with_html %}
+ {{ render_message(child, child_html, depth + 1) }}
+ {% endfor %}
+
+ {% endif %}
+
+{% endmacro %}
+
+{% for root, root_html in roots_with_html %}
+{{ render_message(root, root_html) }}
+{% endfor %}
+```
+
+### JavaScript Simplification (future)
+With nested DOM structure, fold/unfold becomes trivial:
+```javascript
+// Hide all children
+messageEl.querySelector('.children').style.display = 'none';
+// Show children
+messageEl.querySelector('.children').style.display = '';
+```
+
+This would require updating the fold/unfold JavaScript to work with the nested structure rather than CSS class queries.
+
+## Exploration Log
+
+### Phase 1: Foundation ✅ COMPLETE
+- [x] Add `children` field to TemplateMessage (commit `7077f68`)
+- [x] Keep existing flat-list behavior working
+- [x] Add `flatten()` method for backward compatibility (commit `ed4d7b3`)
+ - Instance method `flatten()` returns self + all descendants in depth-first order
+ - Static method `flatten_all()` flattens list of root messages
+ - Unit tests in `test/test_template_data.py::TestTemplateMessageTree`
+
+### Phase 2: Tree Building ✅ COMPLETE
+- [x] Create `_build_message_tree()` function (commit `83fcf31`)
+ - Takes flat list with `message_id` and `ancestry` already set
+ - Populates `children` field based on ancestry
+ - Returns list of root messages (those with empty ancestry)
+- [x] Called after `_mark_messages_with_children()` in render pipeline
+- [x] Integration tests verify tree building doesn't break HTML generation
+
+### Phase 2.5: Tree-First Architecture ✅ COMPLETE (2025-12-13)
+- [x] `generate_template_messages()` now returns tree roots, not flat list (commit `c5048b9`)
+- [x] `HtmlRenderer._flatten_preorder()` traverses tree, formats content, builds flat list
+- [x] Content formatting happens during pre-order traversal (no separate pass)
+- [x] Template unchanged - still receives flat list
+
+**Key insight:** The flat list was being passed to template AND the same messages had children populated. This caused confusion about which structure was authoritative. Now the tree is authoritative and the flat list is derived.
+
+### Phase 3: Template Migration (TODO - Future Work)
+- [ ] Create recursive render macro
+- [ ] Update DOM structure to use nested `.children` divs
+- [ ] Migrate JavaScript fold/unfold to use nested DOM
+- [ ] Pass `root_messages` directly to template (eliminate flatten step)
+
+### Challenges & Notes
+
+**Current State (2025-12-13):**
+- Tree is the primary structure returned from `generate_template_messages()`
+- HtmlRenderer flattens via pre-order traversal for template rendering
+- This is cleaner than before: tree in → flat list out (explicit transformation)
+
+**Performance (2025-12-13):**
+- Benchmark: 3.35s for 3917 messages across 5 projects
+- Pre-order traversal + formatting is O(n)
+- No caching needed - each message formatted exactly once
+
+**Why Keep Flat Template (for now):**
+1. JavaScript fold/unfold relies on CSS class queries
+2. Changing DOM structure requires JS migration
+3. Current approach works correctly
+
+## Related Work
+
+### golergka's text-output-format PR
+Created `content_extractor.py` for shared content parsing:
+- Separates data extraction from presentation
+- Dataclasses for extracted content: `ExtractedText`, `ExtractedToolUse`, etc.
+- Could be extended for the tree-building approach
+
+### Visitor Pattern Consideration
+For multi-format output (HTML, Markdown, JSON), consider:
+- TemplateMessage as a tree data structure (no rendering logic)
+- Visitor implementations for each output format
+- Preparation in converter.py before any rendering
diff --git a/dev-docs/css-classes.md b/dev-docs/css-classes.md
new file mode 100755
index 00000000..1001b78c
--- /dev/null
+++ b/dev-docs/css-classes.md
@@ -0,0 +1,286 @@
+# CSS Classes for Message Types
+
+This document provides a comprehensive reference for CSS class combinations used in Claude Code Log HTML output, their CSS rule support status, and pairing behavior.
+
+**Generated from analysis of:** 29 session HTML files (3,244 message elements)
+**Last updated:** 2025-12-07
+
+---
+
+## Quick Reference
+
+### Support Status Legend
+
+| Status | Meaning |
+|--------|---------|
+| ✅ Full | Has dedicated CSS selectors for this combination |
+| ⚠️ Partial | Inherits from parent selectors only |
+| ❌ None | No CSS rules found |
+
+---
+
+## Base Message Types
+
+| Type | Description | CSS Support |
+|------|-------------|-------------|
+| `assistant` | Assistant response | ✅ Full |
+| `bash-input` | Bash command input | ✅ Full |
+| `bash-output` | Bash command output | ✅ Full |
+| `image` | User-attached image | ✅ Full |
+| `session-header` | Session header divider | ✅ Full |
+| `system` | System message (user-initiated) | ✅ Full |
+| `system-error` | System error (assistant-generated) | ✅ Full |
+| `system-info` | System info message | ✅ Full |
+| `system-warning` | System warning (assistant-generated) | ✅ Full |
+| `thinking` | Extended thinking content | ✅ Full |
+| `tool_result` | Tool result (success) | ✅ Full |
+| `tool_use` | Tool use message | ✅ Full |
+| `user` | Basic user message | ✅ Full |
+| `unknown` | Unknown message type | ❌ None |
+
+---
+
+## Modifier Classes
+
+| Modifier | Applied To | Description |
+|----------|------------|-------------|
+| `compacted` | `user` | Compacted conversation summary |
+| `command-output` | `user` | Slash command output content |
+| `error` | `tool_result` | Tool execution error |
+| `pair_first` | Various | First message in a pair |
+| `pair_last` | Various | Last message in a pair |
+| `pair_middle` | Various | Middle message (never used so far) |
+| `sidechain` | Various | Sub-agent (Task) message |
+| `slash-command` | `user` | Expanded slash command prompt |
+| `steering` | `user` | User steering via queue operation |
+| `system-info` | `system` | System info level |
+| `system-hook` | `system` | Hook execution summary |
+
+---
+
+## Pairing Behavior
+
+Message pairing creates visual groupings for related messages. The `pair_first` and `pair_last` classes control styling of paired messages.
+
+### Pairing Rules by Type
+
+| Base Type | Can Be `pair_first` | Can Be `pair_last` |
+|-----------|---------------------|-------------------|
+| `assistant` | No | Yes |
+| `bash-input` | Yes | No |
+| `bash-output` | No | Yes |
+| `system` | Yes | Yes |
+| `thinking` | Yes | No |
+| `tool_result` | No | Yes |
+| `tool_use` | Yes | No |
+| `user` | No | Yes |
+
+### Common Pairing Patterns
+
+| First Message | Last Message | Linked By |
+|---------------|--------------|-----------|
+| `tool_use` | `tool_result` | `tool_use_id` |
+| `bash-input` | `bash-output` | Sequential |
+| `thinking` | `assistant` | Sequential |
+| `user` (slash-command) | `user` (command-output) | Sequential |
+| `system` (system-info) | `system` (system-info) | Paired info |
+
+---
+
+## All Class Combinations by Support Level
+
+### ✅ Full Support (25 combinations)
+
+These combinations have dedicated CSS selectors:
+
+| Combination | Description | Occurrences |
+|-------------|-------------|-------------|
+| `assistant` | Assistant response | 419 |
+| `assistant ` | Assistant (paired with thinking) | 104 |
+| `assistant sidechain` | Sub-assistant response | 73 |
+| `bash-input` | Bash command input | 5 |
+| `bash-output` | Bash command output | 5 |
+| `image` | Image content | (rare) |
+| `session-header` | Session header divider | 29 |
+| `system` | System message (user-initiated) | 20 |
+| `system system-hook` | Hook summary message | (rare) |
+| `system-error` | System error (assistant-generated) | (rare) |
+| `system-info` | System info message | 118 |
+| `system-warning` | System warning (assistant-generated) | (rare) |
+| `thinking` | Thinking content | 199 |
+| `thinking pair_first` | Thinking (first in pair) | 104 |
+| `thinking sidechain` | Sub-assistant thinking | (rare) |
+| `tool_result` | Tool result (success) | 863 |
+| `tool_result error` | Tool result (error) | 83 |
+| `tool_result sidechain` | Sub-assistant tool result | 83 |
+| `tool_use` | Tool use message | 946 |
+| `tool_use sidechain` | Sub-assistant tool use | 84 |
+| `user` | Basic user message | 88 |
+| `user command-output` | Slash command output | 19 |
+| `user compacted` | Compacted user conversation | (rare) |
+| `user slash-command` | Slash command invocation | 20 |
+| `user steering` | Out-of-band steering input | (rare) |
+
+### ⚠️ Partial Support (7 combinations)
+
+These combinations inherit from parent selectors but have no dedicated rules:
+
+| Combination | Description | Inherits From |
+|-------------|-------------|---------------|
+| `assistant pair_last` | Assistant (last in pair) | `.assistant`, `.` |
+| `tool_result error sidechain` | Sub-assistant tool error | `.tool_result`, `.error`, `.sidechain` |
+| `unknown sidechain` | Unknown sidechain type | `.sidechain` |
+| `user compacted sidechain` | Compacted sidechain user | `.user`, `.compacted`, `.sidechain` |
+| `user sidechain` | Sub-assistant user prompt (deprecated) | `.user`, `.sidechain` |
+| `user slash-command sidechain` | Sidechain slash command | `.user`, `.slash-command`, `.sidechain` |
+| `user command-output pair_last` | Command output in pair | `.user`, `.command-output` |
+
+### ❌ No Support (1 combination)
+
+| Combination | Description | Note |
+|-------------|-------------|------|
+| `unknown` | Unknown message type | Fallback type - should rarely appear |
+
+---
+
+## Fold-Bar Support
+
+The fold-bar component uses `data-border-color` attribute to style borders based on message types. Below shows which combinations have dedicated fold-bar styling.
+
+### Has Fold-Bar Styling (27 combinations)
+
+- `assistant`
+- `assistant sidechain`
+- `bash-input`
+- `bash-output`
+- `image`
+- `image sidechain`
+- `session-header`
+- `system`
+- `system-error`
+- `system-info`
+- `system-warning`
+- `thinking`
+- `thinking sidechain`
+- `tool_result`
+- `tool_result error`
+- `tool_result error sidechain`
+- `tool_result sidechain`
+- `tool_use`
+- `tool_use sidechain`
+- `unknown`
+- `unknown sidechain`
+- `user`
+- `user command-output`
+- `user compacted`
+- `user compacted sidechain`
+- `user sidechain`
+- `user slash-command`
+- `user slash-command sidechain`
+
+### Missing Fold-Bar Styling (5 combinations)
+
+These combinations appear in HTML but lack dedicated fold-bar border colors:
+
+- `assistant ` (uses base `assistant` color)
+- `assistant pair_last` (uses base `assistant` color)
+- `system system-hook` (uses base `system` color)
+- `thinking pair_first` (uses base `thinking` color)
+- `user steering` (uses base `user` color)
+
+---
+
+## Detailed Breakdown by Base Type
+
+### `assistant` (596 occurrences, 3 variations)
+- 419× `assistant` (standalone)
+- 104× `assistant pair_last `
+- 73× `assistant sidechain`
+
+### `bash-input` (5 occurrences, 1 variation)
+- 5× `bash-input pair_first `
+
+### `bash-output` (5 occurrences, 1 variation)
+- 5× `bash-output pair_last `
+
+### `system` (138 occurrences, 3 variations)
+- 59× `system pair_first system-info`
+- 59× `system pair_last system-info`
+- 20× `system pair_first `
+
+### `thinking` (303 occurrences, 2 variations)
+- 199× `thinking` (standalone)
+- 104× `thinking pair_first `
+
+### `tool_result` (1,030 occurrences, 4 variations)
+- 863× `tool_result pair_last `
+- 83× `tool_result error pair_last `
+- 83× `tool_result pair_last sidechain`
+- 1× `tool_result error pair_last sidechain`
+
+### `tool_use` (1,030 occurrences, 2 variations)
+- 946× `tool_use pair_first `
+- 84× `tool_use pair_first sidechain`
+
+### `user` (128 occurrences, 4 variations)
+- 88× `user` (standalone)
+- 20× `user pair_first slash-command`
+- 19× `user command-output pair_last `
+- 1× `user pair_last slash-command` (unpaired)
+
+---
+
+## Key Observations
+
+1. **Pairing Consistency**: Tools (`tool_use` + `tool_result`) and bash commands (`bash-input` + `bash-output`) always appear as pairs, with `pair_first` on the input/use side and `pair_last` on the output/result side.
+
+2. **Thinking-Assistant Pattern**: `thinking` messages that are paired are always `pair_first`, paired with an `assistant` message that is `pair_last`.
+
+3. **Sidechains**: The `sidechain` modifier appears on:
+ - `assistant` messages (73 occurrences)
+ - `tool_use` and `tool_result` pairs (84 and 84 occurrences respectively)
+
+4. **Error Handling**: The `error` modifier only appears on `tool_result` messages (84 total error results).
+
+5. **System Messages**: Have 3 variations:
+ - System info pairs (118 total, always paired)
+ - Generic system pairs (20, `pair_first`)
+
+6. **Slash Commands**: User messages with `slash-command` and `command-output` pair together:
+ - `user slash-command` (20 occurrences, `pair_first`)
+ - `user command-output` (19 occurrences, `pair_last`)
+
+7. **Rare Cases**:
+ - `tool_result` with both `error` and `sidechain` (1 occurrence)
+ - `bash-input`/`bash-output` pairs (5 pairs total)
+
+---
+
+## Structural Classes (Not Semantic)
+
+In addition to the semantic classes above, messages include structural classes:
+
+- **Session IDs**: `session-{uuid}` - identifies which session a message belongs to
+- **Ancestry Markers**: `d-{number}` - indicates descendant depth in the message tree
+
+These are excluded from semantic analysis but appear in all HTML output.
+
+---
+
+## CSS Selector Statistics
+
+- **Total CSS selectors in templates**: 495
+- **Message-related selectors**: 78
+- **Fold-bar combinations**: 28
+- **Full support combinations**: 25
+- **Partial support combinations**: 7
+- **No support combinations**: 1
+
+---
+
+## References
+
+- Source: [css_class_combinations_summary.md](/tmp/css_class_combinations_summary.md)
+- Source: [css_rules_analysis.md](/tmp/css_rules_analysis.md)
+- CSS templates: [claude_code_log/templates/](../claude_code_log/templates/)
+- Messages documentation: [messages.md](messages.md)
diff --git a/dev-docs/messages.md b/dev-docs/messages.md
new file mode 100644
index 00000000..ce455b47
--- /dev/null
+++ b/dev-docs/messages.md
@@ -0,0 +1,793 @@
+# Message Types in Claude Code Transcripts
+
+This document describes all message types found in Claude Code JSONL transcript files and their corresponding output representations. The goal is to define an **intermediate representation** that captures the logical message structure independent of HTML rendering.
+
+## Overview
+
+Claude Code transcripts contain messages in JSONL format. Each line represents an input message that gets transformed through:
+
+1. **Input Layer** (JSONL): Raw Claude Code transcript data
+2. **Intermediate Layer** (TemplateMessage): Format-neutral logical representation
+3. **Output Layer** (HTML): Rendered visual output
+
+This document maps input types to their intermediate and output representations.
+
+---
+
+## Data Flow: From Transcript Entries to Rendered Messages
+
+```
+JSONL Parsing (parser.py)
+│
+├── UserTranscriptEntry
+│ ├── TextContent → User message variants:
+│ │ ├── SlashCommandContent (isMeta or tags)
+│ │ ├── CommandOutputContent ( tags)
+│ │ ├── BashInputContent ( tags)
+│ │ ├── CompactedSummaryContent (compacted conversation)
+│ │ └── Plain user text
+│ ├── ToolResultContent → Tool result messages:
+│ │ ├── ReadOutput (cat-n formatted file content)
+│ │ ├── EditOutput (cat-n formatted edit result)
+│ │ └── Generic tool result text
+│ └── ImageContent → Image messages
+│
+├── AssistantTranscriptEntry
+│ ├── TextContent → AssistantTextContent
+│ ├── ThinkingContent → ThinkingContentModel
+│ └── ToolUseContent → Tool use messages with parsed inputs:
+│ ├── ReadInput, WriteInput, EditInput, MultiEditInput
+│ ├── BashInput, GlobInput, GrepInput
+│ ├── TaskInput, TodoWriteInput, AskUserQuestionInput
+│ └── ExitPlanModeInput
+│
+├── SystemTranscriptEntry
+│ ├── SystemContent (level: info/warning/error)
+│ └── HookSummaryContent (subtype: stop_hook_summary)
+│
+├── SummaryTranscriptEntry → Session metadata (not rendered)
+│
+└── QueueOperationTranscriptEntry
+ └── "remove" operation → Steering message (rendered as user)
+```
+
+---
+
+## Intermediate Representation: TemplateMessage
+
+The intermediate representation is `TemplateMessage`, a Python class (in `renderer.py`) that captures all fields needed for rendering.
+
+### Key Fields
+
+```python
+class TemplateMessage:
+ # Identity
+ type: str # Base type: "user", "assistant", "tool_use", etc.
+ message_id: str # Unique ID within session (e.g., "msg-0", "tool-1")
+ uuid: str # Original JSONL uuid
+
+ # Content (format-neutral)
+ content: Optional[MessageContent] # Structured content model
+ # Note: HTML is generated during template rendering, not stored in the message
+
+ # Display
+ message_title: str # Display title (e.g., "User", "Assistant")
+ css_class: str # CSS classes (derived from type + modifiers)
+ modifiers: MessageModifiers # Format-neutral display traits
+
+ # Metadata
+ raw_timestamp: str # ISO 8601 timestamp
+ session_id: str # Session UUID
+
+ # Hierarchy
+ children: List[TemplateMessage] # Child messages (tree mode)
+ ancestry: List[str] # Parent message IDs for fold/unfold
+
+ # Pairing
+ is_paired: bool # True if part of a pair
+ pair_role: Optional[str] # "pair_first", "pair_last", "pair_middle"
+
+ # Tool-specific
+ tool_use_id: Optional[str] # ID linking tool_use to tool_result
+```
+
+### MessageModifiers → CSS Classes
+
+Display traits are stored in `MessageModifiers` (see [Part 6](#part-6-infrastructure-models)) and converted to CSS classes for HTML rendering:
+
+| css_class | Base Type | MessageModifiers Field |
+|-----------|-----------|------------------------|
+| `"user"` | user | (none) |
+| `"user compacted"` | user | `is_compacted=True` |
+| `"user slash-command"` | user | `is_slash_command=True` |
+| `"user command-output"` | user | `is_command_output=True` |
+| `"user sidechain"` | user | `is_sidechain=True` |
+| `"user steering"` | user | `is_steering=True` |
+| `"assistant"` | assistant | (none) |
+| `"assistant sidechain"` | assistant | `is_sidechain=True` |
+| `"tool_use"` | tool_use | (none) |
+| `"tool_use sidechain"` | tool_use | `is_sidechain=True` |
+| `"tool_result"` | tool_result | (none) |
+| `"tool_result error"` | tool_result | `is_error=True` |
+| `"tool_result sidechain"` | tool_result | `is_sidechain=True` |
+| `"thinking"` | thinking | (none) |
+| `"system system-info"` | system | `system_level="info"` |
+| `"system system-warning"` | system | `system_level="warning"` |
+| `"system system-error"` | system | `system_level="error"` |
+| `"system system-hook"` | system | `system_level="hook"` |
+
+**Note**: See [css-classes.md](css-classes.md) for complete CSS support status.
+
+---
+
+# Part 1: User Messages (UserTranscriptEntry)
+
+User transcript entries (`type: "user"`) contain human input, tool results, and images.
+
+## 1.1 Content Types in User Messages
+
+User messages contain `ContentItem` instances that are either:
+- **TextContent**: User-typed text (with various semantic variants)
+- **ToolResultContent**: Results from tool execution
+- **ImageContent**: User-attached images
+
+## 1.2 User Text Variants
+
+Based on flags and tag patterns in `TextContent`, user text messages are classified into specialized content types defined in `models.py`.
+
+### Regular User Prompt
+
+- **Condition**: No special flags or tags
+- **Content Model**: Plain `TextContent`
+- **CSS Class**: `user`
+- **Files**: [user.json](messages/user/user.json) | [user.jsonl](messages/user/user.jsonl)
+
+```json
+{
+ "type": "user",
+ "message": {
+ "role": "user",
+ "content": [{ "type": "text", "text": "Help me fix this bug..." }]
+ },
+ "isSidechain": false
+}
+```
+
+### Slash Command (isMeta)
+
+- **Condition**: `isMeta: true` flag
+- **Content Model**: `SlashCommandContent` (models.py)
+- **CSS Class**: `user slash-command`
+- **Files**: [user_slash_command.json](messages/user/user_slash_command.json)
+
+```json
+{
+ "type": "user",
+ "message": { "content": "Caveat: The messages below were generated..." },
+ "isMeta": true
+}
+```
+
+### Slash Command (Tags)
+
+- **Condition**: Contains `` tags
+- **Content Model**: `SlashCommandContent` with parsed name/args/contents
+- **CSS Class**: `user slash-command`
+- **Files**: [user_command.json](messages/user/user_command.json)
+
+```python
+@dataclass
+class SlashCommandContent(MessageContent):
+ command_name: str # e.g., "/model", "/context"
+ command_args: str # Arguments after command
+ command_contents: str # Content inside command
+```
+
+### Command Output
+
+- **Condition**: Contains `` tags
+- **Content Model**: `CommandOutputContent`
+- **CSS Class**: `user command-output`
+- **Files**: [command_output.json](messages/user/command_output.json)
+
+```python
+@dataclass
+class CommandOutputContent(MessageContent):
+ stdout: str # Command output text
+ is_markdown: bool # True if content appears to be markdown
+```
+
+### Bash Input
+
+- **Condition**: Contains `` tags
+- **Content Model**: `BashInputContent`
+- **CSS Class**: Part of bash tool pairing
+- **Files**: [bash_input.json](messages/user/bash_input.json)
+
+```python
+@dataclass
+class BashInputContent(MessageContent):
+ command: str # The bash command that was executed
+```
+
+### Bash Output
+
+The corresponding output uses `` and optionally `` tags:
+
+- **Condition**: Contains `` tags
+- **Content Model**: `BashOutputContent`
+- **CSS Class**: Part of bash tool pairing
+- **Files**: [bash_output.json](messages/user/bash_output.json)
+
+### Compacted Conversation
+
+- **Condition**: Contains "(compacted conversation)" marker
+- **Content Model**: `CompactedSummaryContent`
+- **CSS Class**: `user compacted`
+
+```python
+@dataclass
+class CompactedSummaryContent(MessageContent):
+ summary_text: str # The compacted conversation summary
+```
+
+### Sidechain User (Sub-agent)
+
+- **Condition**: `isSidechain: true`
+- **CSS Class**: `user sidechain`
+- **Note**: Typically skipped during rendering (duplicates Task prompt)
+- **Files**: [user_sidechain.json](messages/user/user_sidechain.json)
+
+### IDE Notifications
+
+User messages may contain IDE notification tags that are parsed into structured content:
+
+- **Condition**: Contains ``, ``, or `` tags
+- **Content Model**: `IdeNotificationContent` containing lists of:
+ - `IdeOpenedFile`: File open notifications
+ - `IdeSelection`: Code selection notifications
+ - `IdeDiagnostic`: Diagnostic messages (parsed JSON or raw text fallback)
+- **CSS Class**: Notifications rendered as inline elements within user message
+
+```python
+@dataclass
+class IdeOpenedFile:
+ content: str # Raw content from the tag
+
+@dataclass
+class IdeSelection:
+ content: str # Raw selection content
+
+@dataclass
+class IdeDiagnostic:
+ diagnostics: Optional[List[Dict[str, Any]]] # Parsed JSON
+ raw_content: Optional[str] # Fallback if parsing failed
+
+@dataclass
+class IdeNotificationContent(MessageContent):
+ opened_files: List[IdeOpenedFile]
+ selections: List[IdeSelection]
+ diagnostics: List[IdeDiagnostic]
+ remaining_text: str # Text after notifications extracted
+```
+
+## 1.3 Tool Results (ToolResultContent)
+
+Tool results appear as `ToolResultContent` items in user messages, linked to their corresponding `ToolUseContent` via `tool_use_id`.
+
+### Tool Result Output Models
+
+| Tool | Output Model | Key Fields | Files |
+|------|--------------|------------|-------|
+| Read | `ReadOutput` | file_path, content, start_line, num_lines, is_truncated | [tool_result](messages/tools/Read-tool_result.json) |
+| Edit | `EditOutput` | file_path, success, diffs, message, start_line | [tool_result](messages/tools/Edit-tool_result.json) |
+| Write | `WriteOutput` *(TODO)* | file_path, success, message | [tool_result](messages/tools/Write-tool_result.json) |
+| Bash | `BashOutput` *(TODO)* | stdout, stderr, exit_code, interrupted, is_image | [tool_result](messages/tools/Bash-tool_result.json) |
+| Glob | `GlobOutput` *(TODO)* | pattern, files, truncated | [tool_result](messages/tools/Glob-tool_result.json) |
+| Grep | `GrepOutput` *(TODO)* | pattern, matches, output_mode, truncated | [tool_result](messages/tools/Grep-tool_result.json) |
+| Task | `TaskOutput` *(TODO)* | agent_id, result, is_background | [tool_result](messages/tools/Task-tool_result.json) |
+| (error) | — | is_error: true | [Bash error](messages/tools/Bash-tool_result_error.json) |
+
+**(TODO)**: Output model defined in models.py but not yet used - tool results currently handled as raw strings.
+
+### Generic Tool Result
+
+- **CSS Class**: `tool_result`
+- **Content**: Raw string or structured content
+
+```json
+{
+ "type": "user",
+ "message": {
+ "content": [{
+ "type": "tool_result",
+ "tool_use_id": "toolu_xxx",
+ "is_error": false,
+ "content": "..."
+ }]
+ }
+}
+```
+
+### Tool Result Error
+
+- **Condition**: `is_error: true`
+- **CSS Class**: `tool_result error`
+- **Files**: [Bash-tool_result_error.json](messages/tools/Bash-tool_result_error.json)
+
+### Read Tool Result → ReadOutput
+
+Read tool results in cat-n format are parsed into structured `ReadOutput`:
+- **Files**: [Read-tool_result.json](messages/tools/Read-tool_result.json)
+
+```python
+@dataclass
+class ReadOutput(MessageContent):
+ file_path: str
+ content: str # File content (may be truncated)
+ start_line: int # 1-based starting line number
+ num_lines: int # Number of lines in content
+ total_lines: int # Total lines in file
+ is_truncated: bool
+ system_reminder: Optional[str] # Embedded system reminder
+```
+
+### Edit Tool Result → EditOutput
+
+Edit tool results with cat-n snippets are parsed into structured `EditOutput`:
+- **Files**: [Edit-tool_result.json](messages/tools/Edit-tool_result.json)
+
+```python
+@dataclass
+class EditOutput(MessageContent):
+ file_path: str
+ success: bool
+ diffs: List[EditDiff] # Changes made
+ message: str # Result message or code snippet
+ start_line: int # Starting line for display
+```
+
+### Tool Result Rendering Wrapper
+
+Tool results are wrapped in `ToolResultContentModel` for rendering, which provides additional context:
+
+```python
+@dataclass
+class ToolResultContentModel(MessageContent):
+ tool_use_id: str
+ content: Any # Union[str, List[Dict[str, Any]]]
+ is_error: bool = False
+ tool_name: Optional[str] = None # Name of the tool
+ file_path: Optional[str] = None # File path for Read/Edit/Write
+```
+
+## 1.4 Images (ImageContent)
+
+- **CSS Class**: `image`
+- **Files**: [image.json](messages/user/image.json)
+
+```json
+{
+ "type": "user",
+ "message": {
+ "content": [{
+ "type": "image",
+ "source": {
+ "type": "base64",
+ "media_type": "image/png",
+ "data": "iVBORw0KGgo..."
+ }
+ }]
+ }
+}
+```
+
+Image data is structured using `ImageSource`:
+
+```python
+class ImageSource(BaseModel):
+ type: Literal["base64"]
+ media_type: str # e.g., "image/png"
+ data: str # Base64-encoded image data
+
+class ImageContent(BaseModel, MessageContent):
+ type: Literal["image"]
+ source: ImageSource
+```
+
+---
+
+# Part 2: Assistant Messages (AssistantTranscriptEntry)
+
+Assistant transcript entries (`type: "assistant"`) contain Claude's responses.
+
+## 2.1 Content Types in Assistant Messages
+
+Assistant messages contain `ContentItem` instances that are:
+- **TextContent**: Claude's text response
+- **ThinkingContent**: Extended thinking blocks
+- **ToolUseContent**: Tool invocations
+
+## 2.2 Assistant Text → AssistantTextContent
+
+- **Content Model**: `AssistantTextContent` (models.py)
+- **CSS Class**: `assistant` (or `assistant sidechain`)
+- **Files**: [assistant.json](messages/assistant/assistant.json)
+
+```python
+@dataclass
+class AssistantTextContent(MessageContent):
+ text: str # The assistant's response text
+```
+
+### Sidechain Assistant
+
+- **Condition**: `isSidechain: true`
+- **CSS Class**: `assistant sidechain`
+- **Title**: "Sub-assistant"
+- **Files**: [assistant_sidechain.json](messages/assistant/assistant_sidechain.json)
+
+## 2.3 Thinking Content → ThinkingContentModel
+
+- **Content Model**: `ThinkingContentModel` (models.py)
+- **CSS Class**: `thinking`
+- **Files**: [thinking.json](messages/assistant/thinking.json)
+
+```python
+@dataclass
+class ThinkingContentModel(MessageContent):
+ thinking: str # The thinking text
+ signature: Optional[str] # Thinking block signature
+```
+
+```json
+{
+ "type": "assistant",
+ "message": {
+ "content": [{ "type": "thinking", "thinking": "Let me analyze..." }]
+ }
+}
+```
+
+## 2.4 Tool Use → ToolUseContent with Typed Inputs
+
+Tool invocations contain a `ToolUseContent` item with:
+- `name`: The tool name (e.g., "Read", "Bash", "Task")
+- `id`: Unique ID for pairing with results
+- `input`: Raw input dictionary
+
+The `parsed_input` property returns a typed input model via `parse_tool_input()`.
+
+### Tool Input Models (models.py)
+
+| Tool | Input Model | Key Fields |
+|------|-------------|------------|
+| Read | `ReadInput` | file_path, offset, limit |
+| Write | `WriteInput` | file_path, content |
+| Edit | `EditInput` | file_path, old_string, new_string, replace_all |
+| MultiEdit | `MultiEditInput` | file_path, edits[] |
+| Bash | `BashInput` | command, description, timeout, run_in_background |
+| Glob | `GlobInput` | pattern, path |
+| Grep | `GrepInput` | pattern, path, glob, type, output_mode |
+| Task | `TaskInput` | prompt, subagent_type, description, model |
+| TodoWrite | `TodoWriteInput` | todos[] |
+| AskUserQuestion | `AskUserQuestionInput` | questions[], question |
+| ExitPlanMode | `ExitPlanModeInput` | plan, launchSwarm, teammateCount |
+
+### Tool Input Helper Models
+
+Some tool inputs contain nested structures with their own models:
+
+```python
+# MultiEdit tool uses EditItem for individual edits
+class EditItem(BaseModel):
+ old_string: str
+ new_string: str
+
+# TodoWrite tool uses TodoWriteItem for individual todos
+class TodoWriteItem(BaseModel):
+ content: str = ""
+ status: str = "pending"
+ activeForm: str = ""
+ id: Optional[str] = None
+ priority: Optional[str] = None
+
+# AskUserQuestion tool uses nested models for questions/options
+class AskUserQuestionOption(BaseModel):
+ label: str = ""
+ description: Optional[str] = None
+
+class AskUserQuestionItem(BaseModel):
+ question: str = ""
+ header: Optional[str] = None
+ options: List[AskUserQuestionOption] = []
+ multiSelect: bool = False
+```
+
+### Tool Use Message Structure
+
+- **CSS Class**: `tool_use` (or `tool_use sidechain`)
+- **Files**: See [messages/tools/](messages/tools/) (e.g., `Read-tool_use.json`)
+
+```json
+{
+ "type": "assistant",
+ "message": {
+ "content": [{
+ "type": "tool_use",
+ "id": "toolu_xxx",
+ "name": "Read",
+ "input": { "file_path": "/path/to/file" }
+ }]
+ }
+}
+```
+
+---
+
+# Part 3: System Messages (SystemTranscriptEntry)
+
+System transcript entries (`type: "system"`) convey notifications and hook summaries.
+
+## 3.1 Content Types for System Messages
+
+System messages are parsed into structured content models in `models.py`:
+- **SystemContent**: For info/warning/error messages
+- **HookSummaryContent**: For hook execution summaries
+
+## 3.2 System Info/Warning/Error → SystemContent
+
+- **Content Model**: `SystemContent` (models.py)
+- **CSS Class**: `system system-info`, `system system-warning`, `system system-error`
+- **Files**: [system_info.json](messages/system/system_info.json)
+
+```python
+@dataclass
+class SystemContent(MessageContent):
+ level: str # "info", "warning", "error"
+ text: str # Raw text content (may contain ANSI codes)
+```
+
+```json
+{
+ "type": "system",
+ "content": "Running PostToolUse:MultiEdit...",
+ "level": "info"
+}
+```
+
+## 3.3 Hook Summary → HookSummaryContent
+
+- **Content Model**: `HookSummaryContent` (models.py)
+- **Condition**: `subtype: "stop_hook_summary"`
+- **CSS Class**: `system system-hook`
+
+```python
+@dataclass
+class HookInfo:
+ command: str
+
+@dataclass
+class HookSummaryContent(MessageContent):
+ has_output: bool
+ hook_errors: List[str]
+ hook_infos: List[HookInfo]
+```
+
+---
+
+# Part 4: Metadata Entries
+
+These entry types primarily contain metadata, with some rendered conditionally.
+
+## 4.1 Summary (SummaryTranscriptEntry)
+
+- **Purpose**: Session summary for navigation
+- **Files**: [summary.json](messages/system/summary.json)
+
+```json
+{
+ "type": "summary",
+ "summary": "Claude Code warmup for deep-manifest project",
+ "leafUuid": "b83b0f5f-8bfc-4b98-8368-16162a6e9320"
+}
+```
+
+The `leafUuid` links the summary to the last message of the session.
+
+## 4.2 Queue Operation (QueueOperationTranscriptEntry)
+
+- **Purpose**: User interrupts and steering during assistant responses
+- **Rendered**: Only `remove` operations (as `user steering`)
+- **Files**: [queue_operation.json](messages/system/queue_operation.json)
+
+## 4.3 File History Snapshot
+
+- **Purpose**: File state snapshots for undo/redo
+- **Not Rendered**
+- **Files**: [file_history_snapshot.json](messages/system/file_history_snapshot.json)
+
+---
+
+# Part 5: Renderer Content Models
+
+These models are created during rendering to represent synthesized content not directly from JSONL entries.
+
+## 5.1 SessionHeaderContent
+
+Session headers are rendered at the start of each session:
+
+```python
+@dataclass
+class SessionHeaderContent(MessageContent):
+ title: str # e.g., "Session 2025-12-13 10:30"
+ session_id: str # Session UUID
+ summary: Optional[str] = None # Session summary if available
+```
+
+## 5.2 DedupNoticeContent
+
+Deduplication notices are shown when content is deduplicated (e.g., sidechain assistant text that duplicates the Task tool result):
+
+```python
+@dataclass
+class DedupNoticeContent(MessageContent):
+ notice_text: str # e.g., "Content omitted (duplicates Task result)"
+```
+
+---
+
+# Part 6: Infrastructure Models
+
+## 6.1 MessageModifiers
+
+Semantic modifiers that affect message display. These are format-neutral flags that renderers use to determine how to display a message:
+
+```python
+@dataclass
+class MessageModifiers:
+ is_sidechain: bool = False # Sub-agent message
+ is_slash_command: bool = False # Slash command invocation
+ is_command_output: bool = False # Command output
+ is_compacted: bool = False # Compacted conversation
+ is_error: bool = False # Error message
+ is_steering: bool = False # Queue remove (steering)
+ system_level: Optional[str] = None # "info", "warning", "error", "hook"
+```
+
+## 6.2 UsageInfo
+
+Token usage tracking for assistant messages:
+
+```python
+class UsageInfo(BaseModel):
+ input_tokens: Optional[int] = None
+ cache_creation_input_tokens: Optional[int] = None
+ cache_read_input_tokens: Optional[int] = None
+ output_tokens: Optional[int] = None
+ service_tier: Optional[str] = None
+ server_tool_use: Optional[Dict[str, Any]] = None
+```
+
+## 6.3 BaseTranscriptEntry
+
+Base class for all transcript entries, providing common fields:
+
+```python
+class BaseTranscriptEntry(BaseModel):
+ parentUuid: Optional[str] # UUID of parent message
+ isSidechain: bool # Whether this is a sub-agent message
+ userType: str # User type identifier
+ cwd: str # Working directory
+ sessionId: str # Session UUID
+ version: str # Transcript format version
+ uuid: str # Unique message ID
+ timestamp: str # ISO 8601 timestamp
+ isMeta: Optional[bool] = None # Slash command marker
+ agentId: Optional[str] = None # Sub-agent ID
+```
+
+---
+
+# Part 7: Message Relationships
+
+## 7.1 Hierarchy (Parent/Child)
+
+The message hierarchy is determined by **sequence and message type**, not by `parentUuid`:
+
+- Session headers are topmost (Level 0)
+- User messages follow at Level 1
+- Assistant responses and system messages nest under user messages (Level 2)
+- Tool use/result pairs nest under assistant responses (Level 3)
+- Sidechain messages nest under their Task result (Level 4+)
+
+```
+Session header (Level 0)
+└── User message (Level 1)
+ ├── System message (Level 2)
+ └── Assistant response (Level 2)
+ └── Tool use/result pair (Level 3)
+ └── Sidechain messages (Level 4+)
+```
+
+**Note**: `parentUuid` links messages temporally (which message preceded this one) but is not used for rendering hierarchy.
+
+## 7.2 Tool Pairing
+
+`tool_use` and `tool_result` messages are paired by `tool_use_id`:
+
+| First | Last | Link |
+|-------|------|------|
+| `tool_use` | `tool_result` | `tool_use.id` = `tool_result.tool_use_id` |
+
+### Other Pairings
+
+| First | Last | Link |
+|-------|------|------|
+| `bash-input` | `bash-output` | Sequential |
+| `thinking` | `assistant` | Sequential |
+| `slash-command` | `command-output` | Sequential |
+
+## 7.3 Sidechain Linking
+
+Sub-agent messages (from `Task` tool):
+- Have `isSidechain: true`
+- Have `agentId` linking to the Task
+- Appear nested under their Task result
+
+---
+
+# Part 8: Tool Reference
+
+## Available Tools by Category
+
+### File Operations
+
+| Tool | Use Sample | Result Sample | Input Model | Output Model |
+|------|------------|---------------|-------------|--------------|
+| Read | [tool_use](messages/tools/Read-tool_use.json) | [tool_result](messages/tools/Read-tool_result.json) | `ReadInput` | `ReadOutput` |
+| Write | [tool_use](messages/tools/Write-tool_use.json) | [tool_result](messages/tools/Write-tool_result.json) | `WriteInput` | `WriteOutput` *(TODO)* |
+| Edit | [tool_use](messages/tools/Edit-tool_use.json) | [tool_result](messages/tools/Edit-tool_result.json) | `EditInput` | `EditOutput` |
+| MultiEdit | [tool_use](messages/tools/MultiEdit-tool_use.json) | [tool_result](messages/tools/MultiEdit-tool_result.json) | `MultiEditInput` | — |
+| Glob | [tool_use](messages/tools/Glob-tool_use.json) | [tool_result](messages/tools/Glob-tool_result.json) | `GlobInput` | `GlobOutput` *(TODO)* |
+| Grep | [tool_use](messages/tools/Grep-tool_use.json) | [tool_result](messages/tools/Grep-tool_result.json) | `GrepInput` | `GrepOutput` *(TODO)* |
+
+### Shell Operations
+
+| Tool | Use Sample | Result Sample | Input Model | Output Model |
+|------|------------|---------------|-------------|--------------|
+| Bash | [tool_use](messages/tools/Bash-tool_use.json) | [tool_result](messages/tools/Bash-tool_result.json) | `BashInput` | `BashOutput` *(TODO)* |
+| BashOutput | [tool_use](messages/tools/BashOutput-tool_use.json) | [tool_result](messages/tools/BashOutput-tool_result.json) | — | — |
+| KillShell | [tool_use](messages/tools/KillShell-tool_use.json) | [tool_result](messages/tools/KillShell-tool_result.json) | — | — |
+
+### Agent Operations
+
+| Tool | Use Sample | Result Sample | Input Model | Output Model |
+|------|------------|---------------|-------------|--------------|
+| Task | [tool_use](messages/tools/Task-tool_use.json) | [tool_result](messages/tools/Task-tool_result.json) | `TaskInput` | `TaskOutput` *(TODO)* |
+| TodoWrite | [tool_use](messages/tools/TodoWrite-tool_use.json) | [tool_result](messages/tools/TodoWrite-tool_result.json) | `TodoWriteInput` | — |
+| AskUserQuestion | [tool_use](messages/tools/AskUserQuestion-tool_use.json) | [tool_result](messages/tools/AskUserQuestion-tool_result.json) | `AskUserQuestionInput` | — |
+| ExitPlanMode | [tool_use](messages/tools/ExitPlanMode-tool_use.json) | [tool_result](messages/tools/ExitPlanMode-tool_result.json) | `ExitPlanModeInput` | — |
+
+### Web Operations
+
+| Tool | Use Sample | Result Sample | Input Model | Output Model |
+|------|------------|---------------|-------------|--------------|
+| WebFetch | [tool_use](messages/tools/WebFetch-tool_use.json) | [tool_result](messages/tools/WebFetch-tool_result.json) | — | — |
+| WebSearch | [tool_use](messages/tools/WebSearch-tool_use.json) | [tool_result](messages/tools/WebSearch-tool_result.json) | — | — |
+
+---
+
+## References
+
+- [css-classes.md](css-classes.md) - Complete CSS class reference with support status
+- [models.py](../claude_code_log/models.py) - Pydantic models for transcript data
+- [renderer.py](../claude_code_log/renderer.py) - Main rendering module
+- [html/](../claude_code_log/html/) - HTML-specific formatters (formatting only, content models in models.py)
+ - [system_formatters.py](../claude_code_log/html/system_formatters.py) - SystemContent, HookSummaryContent formatting
+ - [user_formatters.py](../claude_code_log/html/user_formatters.py) - User message formatting
+ - [assistant_formatters.py](../claude_code_log/html/assistant_formatters.py) - AssistantText, Thinking, Image formatting
+ - [tool_formatters.py](../claude_code_log/html/tool_formatters.py) - Tool use/result formatting
+- [parser.py](../claude_code_log/parser.py) - JSONL parsing module
+- [TEMPLATE_MESSAGE_CHILDREN.md](TEMPLATE_MESSAGE_CHILDREN.md) - Tree architecture exploration
+- [MESSAGE_REFACTORING.md](MESSAGE_REFACTORING.md) - Refactoring plan
diff --git a/dev-docs/messages/assistant/assistant.json b/dev-docs/messages/assistant/assistant.json
new file mode 100644
index 00000000..6cbdbe76
--- /dev/null
+++ b/dev-docs/messages/assistant/assistant.json
@@ -0,0 +1,20 @@
+{
+ "type": "assistant",
+ "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed",
+ "timestamp": "2025-09-29T17:07:50.508Z",
+ "uuid": "6610c2dd-f12c-4fc1-b1d4-fa78c1612692",
+ "parentUuid": "39ea49bc-8cc9-4ec3-b598-4d75428d7c5e",
+ "isSidechain": false,
+ "message": {
+ "role": "assistant",
+ "type": "message",
+ "model": "claude-opus-4-1-20250805",
+ "id": "msg_01NtyE53hx2q89rMBGuw6qKD",
+ "content": [
+ {
+ "type": "text",
+ "text": "I'll help you rewrite this to use proper HTML ruby elements, which have better browser support than the CSS `ruby-base` and `ruby-text` display values.\n\nLet me first examine the current structure to u... [truncated]"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/assistant/assistant.jsonl b/dev-docs/messages/assistant/assistant.jsonl
new file mode 100644
index 00000000..25fd5235
--- /dev/null
+++ b/dev-docs/messages/assistant/assistant.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "39ea49bc-8cc9-4ec3-b598-4d75428d7c5e", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01NtyE53hx2q89rMBGuw6qKD", "type": "message", "role": "assistant", "model": "claude-opus-4-1-20250805", "content": [{"type": "text", "text": "I'll help you rewrite this to use proper HTML ruby elements, which have better browser support than the CSS `ruby-base` and `ruby-text` display values.\n\nLet me first examine the current structure to understand how it's being used:"}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 4756, "cache_read_input_tokens": 12008, "cache_creation": {"ephemeral_5m_input_tokens": 4756, "ephemeral_1h_input_tokens": 0}, "output_tokens": 2, "service_tier": "standard"}}, "requestId": "req_011CTd4PoK9LMzcZt6RWbVTR", "type": "assistant", "uuid": "6610c2dd-f12c-4fc1-b1d4-fa78c1612692", "timestamp": "2025-09-29T17:07:50.508Z"}
diff --git a/dev-docs/messages/assistant/assistant_sidechain.json b/dev-docs/messages/assistant/assistant_sidechain.json
new file mode 100644
index 00000000..92205cd7
--- /dev/null
+++ b/dev-docs/messages/assistant/assistant_sidechain.json
@@ -0,0 +1,20 @@
+{
+ "type": "assistant",
+ "sessionId": "7864f562-717b-4d70-a1cb-b588f7826a1a",
+ "timestamp": "2025-10-29T16:03:08.981Z",
+ "uuid": "dfcf5df8-10d0-4b02-a2a0-3775a96225d3",
+ "parentUuid": "86a390e3-356f-4e9b-9584-cd5d5b9af948",
+ "isSidechain": true,
+ "message": {
+ "role": "assistant",
+ "type": "message",
+ "model": "claude-sonnet-4-5-20250929",
+ "id": "msg_018gYNPTHWry5dDGwS3tQ6BV",
+ "content": [
+ {
+ "type": "text",
+ "text": "I'm ready to help you search through your codebase! I can see this is a Next.js blog project with several blog posts and projects. \n\nWhat would you like me to help you find? I can:\n... [truncated]"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/assistant/assistant_sidechain.jsonl b/dev-docs/messages/assistant/assistant_sidechain.jsonl
new file mode 100644
index 00000000..762c118d
--- /dev/null
+++ b/dev-docs/messages/assistant/assistant_sidechain.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "86a390e3-356f-4e9b-9584-cd5d5b9af948", "isSidechain": true, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "7864f562-717b-4d70-a1cb-b588f7826a1a", "version": "2.0.28", "gitBranch": "main", "agentId": "b1f5d80e", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_018gYNPTHWry5dDGwS3tQ6BV", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "I'm ready to help you search through your codebase! I can see this is a Next.js blog project with several blog posts and projects. \n\nWhat would you like me to help you find? I can:\n- Locate specific files using patterns\n- Search for code or text content\n- Read and analyze specific files\n- Navigate through your project structure\n\nWhat are you looking for today?"}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 3, "cache_creation_input_tokens": 1374, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 1374, "ephemeral_1h_input_tokens": 0}, "output_tokens": 87, "service_tier": "standard"}}, "requestId": "req_011CUbmj9zcNSzAQzdYyNMk6", "type": "assistant", "uuid": "dfcf5df8-10d0-4b02-a2a0-3775a96225d3", "timestamp": "2025-10-29T16:03:08.981Z"}
diff --git a/dev-docs/messages/assistant/thinking.json b/dev-docs/messages/assistant/thinking.json
new file mode 100644
index 00000000..8eb662ef
--- /dev/null
+++ b/dev-docs/messages/assistant/thinking.json
@@ -0,0 +1,20 @@
+{
+ "type": "assistant",
+ "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a",
+ "timestamp": "2025-09-29T18:01:57.835Z",
+ "uuid": "96acdb48-646c-415f-9528-722902e9fb6e",
+ "parentUuid": "7002bd4a-4559-454c-bca3-b40729ce9246",
+ "isSidechain": false,
+ "message": {
+ "role": "assistant",
+ "type": "message",
+ "model": "claude-opus-4-1-20250805",
+ "id": "msg_01CkR2ph1853oo3iZdeTXBvJ",
+ "content": [
+ {
+ "type": "thinking",
+ "thinking": "The user is asking me to:\n1. Read three files related to a tokenizer application\n2. Do a thorough code review\n... [truncated]"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/assistant/thinking.jsonl b/dev-docs/messages/assistant/thinking.jsonl
new file mode 100644
index 00000000..d25c0650
--- /dev/null
+++ b/dev-docs/messages/assistant/thinking.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "7002bd4a-4559-454c-bca3-b40729ce9246", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "f852ad25-1024-47da-964e-5eaae5bd6e6a", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01CkR2ph1853oo3iZdeTXBvJ", "type": "message", "role": "assistant", "model": "claude-opus-4-1-20250805", "content": [{"type": "thinking", "thinking": "The user is asking me to:\n1. Read three files related to a tokenizer application\n2. Do a thorough code review\n3. Keep it simple and dependency-free (except for transformers.js)\n4. Keep it compilation-free\n5. Look at TODOs and propose a refactoring plan\n6. Keep the same 3 file structure\n7. Add features from TODOs and make the JavaScript neater\n\nI'm in plan mode, so I should not make any changes yet, just analyze and propose a plan.\n\nLet me analyze what I've read:\n\nThe application is an online LLM tokenizer that:\n- Runs entirely in the browser\n- Loads tokenizer models from HuggingFace\n- Shows token counts and visualizes tokens with colors\n- Stores models in localStorage\n- Has a textarea for input text\n- Can add custom models from HuggingFace\n\nTODOs I found:\n1. In tokenizer.js line 2: Consider upgrading to transformers.js v3 with WebGPU support\n2. In tokenizer.js line 21: Take model list from URL params\n3. In tokenizer.js line 91-92: Add delete button for models and make them reorderable\n4. In tokenizer.js line 97: See if it would be possible to render after each model loaded\n5. In tokenizer.js line 110: Do tokenization in a worker\n6. In tokenizer.html line 13: Add link to Github\n7. In tokenizer.html line 24: Take text from URL params\n8. In tokenizer.html line 34: Add styling to the add model section\n\nLet me do a thorough code review:\n\n**HTML (tokenizer.html):**\n- Clean and simple structure\n- Good use of semantic HTML\n- Missing Github link (TODO)\n- Could benefit from URL param support for text and models\n- The add model section needs better styling\n\n**CSS (tokenizer.css):**\n\n\nMinimalist design with effective dark mode implementation. The styling elegantly handles token visualization and layout. Subtle background coloring for tokenized text creates clear visual separation. Ruby positioning demonstrates careful attention to typographic details. The current styling provides a solid foundation with room for incremental improvements to the model addition interface.\n\n**JavaScript (tokenizer.js):**\nSolid core functionality with several optimization opportunities. The current implementation manages model loading and tokenization effectively, but lacks advanced features like model management and performance optimization. Key improvements could streamline user experience and enhance application responsiveness.\n\nPotential architectural enhancements include leveraging Web Workers for background processing, implementing dynamic URL configuration sharing, and creating more flexible model interaction mechanisms. The goal is to transform the current implementation into a more robust, user-friendly tokenization tool with enhanced performance and configurability.", "signature": "Ev8VCkYICBgCKkCWzxqq3aFDFB0xYRSwcFyYFw+IrZh0P0SNbyr8Qaq0/9pzG8L66PWfe4nEX5FmUVSXuphz+2r/v+lnLuSZKrqaEgzZVBiSdzmQsP2O3wQaDEncYPeh2Yx56E+d7iIwRunjzQt0YlrGyrTc6Nf5hV8+cYuDQjQZkj3pTjOy/+3PdTkkk5j8LU56COFyXn0rKuYUOeAZ5pU0Soh9WIMwSlgLPem3vJRF+GfC3QC4fUr2PEXvoI5cwOOcsHY8jZ6ixNKYXncLWwba8Ij13IB+gUuCrB97FJ27OOhk/Kk11E69gZJGCSFqb8ZmX8qwKBoxGmBoykhIhTg+UMLHP/jIN/vbOz1ed3yvfcrJMFgZlg3RG5THUSVLhF0ma53kEE7VweInSk2PHOgU0xeqZX4/CJvobAnNZ2OrsW8bxKKP0rdexMiQ2kA9ck1wckcXBt568F7CM4Pn7xvphEH5D8NBJ1QUK1waNeWRlj+f32Y1j8PM2w5WuJiGlDzQiJQHzrn8FvxCOoYozEgG+M5zBv4++cR3OAdG5gyuJNdF4JZKGA7mPA/ec+1DTXg4wfJrPe6p4EMYmcaLJJa8az9BwQ3qDCiIl7pIQcPGsrsSRd1nCXQs8ykMSgSOq5P8ZKpOJG0+tLiTQdhy0p8wZ+XKU22ni8SexAUW9npryT7CRCz3EsAQ6hlu/+JISnNVBHBSSEcp/iVqvY7BSl5Fa0CChNBTkLZEolkY1zeCECQyNdpmTio6XQQU8UU0SNU287y6g/HGaadVhcY0d/Cch/zN4SuShg6GYaJdpE+CHcHZLFzakQY6ssrdsnTWfAMhXznA+AXjdH+2COskx2vZAPtH5dWhDRxiuw6bsDXAIZd5UfFVMws2Nbpdv0IsRL6CBN5YRZmzcORrIMitNpz/qmEZPsiTG3hZfM0f+L3CYHW5Ts/BPNbnr48JreprJAwUYgePkyjm6+SevhW38tw3mj2alh6rOmqa1Aet7e9XuhAoaVLWJHPCnW8Eqaebl8T/YdIunVLyf2+emEe+D6xYbrgymkvJRo3mb7CJoU/RnmrV+5DEa99J1fFlIBV0+i0vZys1ozv93Jh8y11z0l9g3+5GsbXsfZfhmPC3BcLVTY4WwK5c63uW+yrnrxu94ZMXST8YmiU2Qw+zpqwSiABYddcDy9xgqLPu6kG8d0NTQNfix8FuT6fHAUmiOM43KDODRlCPYX8LX5o8RhWNZ0JIOaFORMZlkseEmItgmSVXxf9354JRf2cbhFrJiqEvRHk+uPlsOTm7j+Kd3H49fGGNNYJqRprjn/0l3jeAqr87dwH3FTtt48uOpCui50yZ5b3XG+0oQ4FOzuShIZ5/ZLWcJYxpf0S8KQZKcTvtAVCOY0q+HXyi80Cd0A7CyvJiw5rcp5a7/sdFJJBfTGQSTG46qmUjQKW3IGf4FeYPrioLF4gxRGj2QF12h0PHAGRVmue82bEbiRwoxnKbGD4Zo8bQrKlup8nM0ZgXVYalNJJzhLhOHOkd1BB4zVZ5IFEF9vbzfDnAyDZilSO3+gMxGqfKoz6ubIpXpr6WhNa7pQUH5Q20PnNZG28OULytIrkOPgksWZXbqWpAFATIm93VJ9MPYgnROj1Vkl8QnPjbcneZreXqUdej5cyhTUmB/W/lVf7fDBxMqkLTUdX6jFflj1fVEHERh2ML5qoDRvjXhlSTSHkXUq0pXlH3XVcb4Ey9LSUpR+j6bhrdcZLxoH1oklo0XW2+VbUGXTrKPhp/FZjdT+GSqXyl9E6VtNqlDvRkWSaSTY3Ddj3UIweKveghktOPik4vQe6qJWMPZjumtacFwOuF1OVR+BsJV3KAgsrm5/x0GFpSarN82I7izvoWZiFHviSa/MFUYB0lKQkBdvP5CJjf3FJvBxLil8+2cOjdZHKfqPoWtel9iaSP6wmQDuWN6BEfOXoQrio4m0ZW9K74D+Z9WXJhzKhfxhW2alQq/QXiG+MUp2YP05hGC8zIl1EpTVyAv7TEgAQj/zT35yoDJhGjt+HM9ajEZ61BrP3K1g+4oknIwdsFw5Jz3loMVfQSUOoYwwFCMYC08NmZdleV7CB9Cd5q3BfFCKKA7wq+OLV1i20i/r908bPmbCHP/v4sPn0qLuhg0K/1/dyMNPVhR2JG85/nUgg4thC3/yxWCNAWSx723ZwxwO2cmXHgNpXZO41P2KvL5s6E8cxcmmzP2UgDrWifuGjtd86gax+HN+QltDqjylLp+ksT+2Vo5sAHOLH+jAWOyX0zHBmSfIO83XMshioNIrayO4wgAYHjiPyIhQVTj9epRQozR+9CGtVX93n0FG7qu0pjqzQ/Q2OOns12ZYHjTCtOr9FOi7WBBZz1btkyK2ejJZqPhn2KDjRveqBCGNtvFALIdrDio+CmOmaLShBPSF3FVeVUcXNErrgDnzWjXbw7kHkq4LBlfzo+hCJVbNnq5zEMwIWRPJLFLoBaKR7qDPWp1lCYj7EEd9yMXB6IhR2aqa+QPgL8WFTVpME/MngUclCI6JvL83E5UyTia7NQ5bFMEsFE+9KitA9s8LJWF7Oh9pcdysHxBOfIcKU4vynvIy2+MlmXDV+XnlctoX128q7rfRK432GmHrQi8fs1htNXYQZdlg4FwCxc25tcqVDqhUDiJO4KWaHiJ2uACQ/ZpbnaX6wWqi+d80MuqiCrfFwYWXddVeaJQrRlk47wtI7m1/et7TvPSnhuIQRiYOdHFey11slhtiSGA12hGgBZMOb21UzYrFqVrPdMV0p/kFdqPm1WHJwT5llIt6oxtIiGlf6d726DW0o2Qehq5biJNxqRDFwjIesiTzekMuDNFu2x0hKBIvIOk4MgkA7Xlf8pbdLABMBIgszssAPtizTmmTLC+/HefMbGGb8qMwboioUgRF8jpCSAautw1FJoImrRDYpncNgEP1Iza52awdH5tLtbZDu1u2tQIxU+bE6Z651gp7H9MG47FWZ1E8RDaxwcgecZy9Yc1nh5kLeoRauMM25/jR6Qch0vSGFw4WFfFe5h5RMsNSi2AFbm9qW/HilJckieb8SJPKp2Hktx+fw7wET1aBSf9OVDdjQhKq4nGlio5sQOc1jC/7XFwI7JAL1uFNHlNpNW7E58TUy8z22QNJyphN3QO+BEKlyzTT+v6e7gUzPjZ5siaRznc7BdFp7AuyPLd2oyEM6DHZdBqpW29TK6bjWHfgABwJSpWC8lER30Izqe7FyXwgw6QQ/yYRtGzjJnamBxVfPjO9zhUmRS3l4/5NT3qZ14j7hjdxdKQ57NQn+taVf6EVF0VOjKPQBTrQH2JdIG32DP2/NWUv3PyeOlk1uFuEC0GLDcQJlO7OYPkKbd9VsByx2OjsxkNIjvK2F9GGdPQTZ7x8PD4JuvfRTEwUDQyGUtGBJLhK/t921Vjlea+e+jLCeAXW6D1GN10szIzdBbHsXSE1JMhJ5wmm+AA4cnBhlw8Y7wDRQDuI0+DEnC/Oy8qqK4gmqGxst3lzMduyDcxenT0O49ryds5GXhLcGkXdwa197CKBCht1o5NYcJCNBDrPBAao3SAcDRmaTI/XkrQRVRf68RLT7c504n6A2bKzCQ0kNHXkB47wgYRMLFaUsVhUhe+216cI3Gy4jVLpw/8HXrwVmR1/3DvCYo9jD1ZuT62pd7N1qBm8h+h+1OuezrPdMLZ4xQ66FVulH52HHiVNXLHFswvjppGGDFy5x1hlzBMtyhr+tHj0ueZDWhtRb8GQaqHhgB"}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 10, "cache_creation_input_tokens": 8827, "cache_read_input_tokens": 12008, "cache_creation": {"ephemeral_5m_input_tokens": 8827, "ephemeral_1h_input_tokens": 0}, "output_tokens": 4, "service_tier": "standard"}}, "requestId": "req_011CTd8VeEaZzJX8LJjnFZ6V", "type": "assistant", "uuid": "96acdb48-646c-415f-9528-722902e9fb6e", "timestamp": "2025-09-29T18:01:57.835Z"}
diff --git a/dev-docs/messages/system/file_history_snapshot.json b/dev-docs/messages/system/file_history_snapshot.json
new file mode 100644
index 00000000..7974be8a
--- /dev/null
+++ b/dev-docs/messages/system/file_history_snapshot.json
@@ -0,0 +1,3 @@
+{
+ "type": "file-history-snapshot"
+}
\ No newline at end of file
diff --git a/dev-docs/messages/system/file_history_snapshot.jsonl b/dev-docs/messages/system/file_history_snapshot.jsonl
new file mode 100644
index 00000000..0cbc53d5
--- /dev/null
+++ b/dev-docs/messages/system/file_history_snapshot.jsonl
@@ -0,0 +1 @@
+{"type": "file-history-snapshot", "messageId": "fbfaa419-c032-467f-a791-7b0ac66539a6", "snapshot": {"messageId": "fbfaa419-c032-467f-a791-7b0ac66539a6", "trackedFileBackups": {}, "timestamp": "2025-11-29T15:16:58.437Z"}, "isSnapshotUpdate": false}
diff --git a/dev-docs/messages/system/queue_operation.json b/dev-docs/messages/system/queue_operation.json
new file mode 100644
index 00000000..ed89d042
--- /dev/null
+++ b/dev-docs/messages/system/queue_operation.json
@@ -0,0 +1,11 @@
+{
+ "type": "queue-operation",
+ "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e",
+ "timestamp": "2025-11-17T23:50:06.046Z",
+ "content": [
+ {
+ "type": "text",
+ "text": "/init"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/dev-docs/messages/system/queue_operation.jsonl b/dev-docs/messages/system/queue_operation.jsonl
new file mode 100644
index 00000000..310becab
--- /dev/null
+++ b/dev-docs/messages/system/queue_operation.jsonl
@@ -0,0 +1 @@
+{"type": "queue-operation", "operation": "enqueue", "timestamp": "2025-11-17T23:50:06.046Z", "content": [{"type": "text", "text": "/init"}], "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e"}
diff --git a/dev-docs/messages/system/summary.json b/dev-docs/messages/system/summary.json
new file mode 100644
index 00000000..b0aec81c
--- /dev/null
+++ b/dev-docs/messages/system/summary.json
@@ -0,0 +1,5 @@
+{
+ "type": "summary",
+ "summary": "CSS Details Margin Styling",
+ "leafUuid": "f29ff328-9634-4c27-8fc6-5e04c3ee78cc"
+}
\ No newline at end of file
diff --git a/dev-docs/messages/system/summary.jsonl b/dev-docs/messages/system/summary.jsonl
new file mode 100644
index 00000000..e984c972
--- /dev/null
+++ b/dev-docs/messages/system/summary.jsonl
@@ -0,0 +1 @@
+{"type": "summary", "summary": "CSS Details Margin Styling", "leafUuid": "f29ff328-9634-4c27-8fc6-5e04c3ee78cc"}
diff --git a/dev-docs/messages/system/system_info.json b/dev-docs/messages/system/system_info.json
new file mode 100644
index 00000000..954b1352
--- /dev/null
+++ b/dev-docs/messages/system/system_info.json
@@ -0,0 +1,11 @@
+{
+ "type": "system",
+ "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6",
+ "timestamp": "2025-07-19T14:37:16.848Z",
+ "uuid": "1cb795e0-0e78-4c35-b232-c8e554323156",
+ "parentUuid": "46d6d96c-df81-465f-860a-cd26d2b9e38f",
+ "isSidechain": false,
+ "isMeta": false,
+ "level": "info",
+ "content": "Running \u001b[1mPostToolUse:MultiEdit\u001b[22m..."
+}
\ No newline at end of file
diff --git a/dev-docs/messages/system/system_info.jsonl b/dev-docs/messages/system/system_info.jsonl
new file mode 100644
index 00000000..7e32f943
--- /dev/null
+++ b/dev-docs/messages/system/system_info.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "46d6d96c-df81-465f-860a-cd26d2b9e38f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/claude-code-log", "sessionId": "cbc0f75b-b36d-4efd-a7da-ac800ea30eb6", "version": "1.0.55", "gitBranch": "main", "type": "system", "content": "Running \u001b[1mPostToolUse:MultiEdit\u001b[22m...", "isMeta": false, "timestamp": "2025-07-19T14:37:16.848Z", "uuid": "1cb795e0-0e78-4c35-b232-c8e554323156", "toolUseID": "toolu_019WJBjsr6mbHmqXJgQ1mq2L", "level": "info"}
diff --git a/dev-docs/messages/tools/AskUserQuestion-tool_result.json b/dev-docs/messages/tools/AskUserQuestion-tool_result.json
new file mode 100644
index 00000000..31c84765
--- /dev/null
+++ b/dev-docs/messages/tools/AskUserQuestion-tool_result.json
@@ -0,0 +1,20 @@
+{
+ "type": "user",
+ "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a",
+ "timestamp": "2025-11-17T11:24:30.745Z",
+ "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1",
+ "parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7",
+ "is_error": true,
+ "content": "Error: No such tool available: AskUserQuestion "
+ }
+ ]
+ },
+ "toolUseResult": "Error: No such tool available: AskUserQuestion"
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/AskUserQuestion-tool_result.jsonl b/dev-docs/messages/tools/AskUserQuestion-tool_result.jsonl
new file mode 100644
index 00000000..9574ec75
--- /dev/null
+++ b/dev-docs/messages/tools/AskUserQuestion-tool_result.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "Error: No such tool available: AskUserQuestion ", "is_error": true, "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7"}]}, "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1", "timestamp": "2025-11-17T11:24:30.745Z", "toolUseResult": "Error: No such tool available: AskUserQuestion"}
diff --git a/dev-docs/messages/tools/AskUserQuestion-tool_result_error.json b/dev-docs/messages/tools/AskUserQuestion-tool_result_error.json
new file mode 100644
index 00000000..31c84765
--- /dev/null
+++ b/dev-docs/messages/tools/AskUserQuestion-tool_result_error.json
@@ -0,0 +1,20 @@
+{
+ "type": "user",
+ "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a",
+ "timestamp": "2025-11-17T11:24:30.745Z",
+ "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1",
+ "parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7",
+ "is_error": true,
+ "content": "Error: No such tool available: AskUserQuestion "
+ }
+ ]
+ },
+ "toolUseResult": "Error: No such tool available: AskUserQuestion"
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/AskUserQuestion-tool_result_error.jsonl b/dev-docs/messages/tools/AskUserQuestion-tool_result_error.jsonl
new file mode 100644
index 00000000..9574ec75
--- /dev/null
+++ b/dev-docs/messages/tools/AskUserQuestion-tool_result_error.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "Error: No such tool available: AskUserQuestion ", "is_error": true, "tool_use_id": "toolu_013Cho8SURc4ESongaWZu4d7"}]}, "uuid": "c37b9c09-2cf8-4d20-afcf-60d2f90f0eb1", "timestamp": "2025-11-17T11:24:30.745Z", "toolUseResult": "Error: No such tool available: AskUserQuestion"}
diff --git a/dev-docs/messages/tools/AskUserQuestion-tool_use.json b/dev-docs/messages/tools/AskUserQuestion-tool_use.json
new file mode 100644
index 00000000..86feaead
--- /dev/null
+++ b/dev-docs/messages/tools/AskUserQuestion-tool_use.json
@@ -0,0 +1,24 @@
+{
+ "type": "assistant",
+ "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a",
+ "timestamp": "2025-11-17T11:24:30.683Z",
+ "uuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f",
+ "parentUuid": "e753d86a-d3a6-409d-90b6-6064080be49c",
+ "isSidechain": false,
+ "message": {
+ "role": "assistant",
+ "type": "message",
+ "model": "claude-sonnet-4-5-20250929",
+ "id": "msg_0128UrZy2Lq7Tdn7FYWLDgi7",
+ "content": [
+ {
+ "type": "tool_use",
+ "id": "toolu_013Cho8SURc4ESongaWZu4d7",
+ "name": "AskUserQuestion",
+ "input": {
+ "question": "I need to understand your preferred installation approach to give you the best solution:\n\n... [trunc... [truncated]"
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/AskUserQuestion-tool_use.jsonl b/dev-docs/messages/tools/AskUserQuestion-tool_use.jsonl
new file mode 100644
index 00000000..8ca0677d
--- /dev/null
+++ b/dev-docs/messages/tools/AskUserQuestion-tool_use.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "e753d86a-d3a6-409d-90b6-6064080be49c", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/coderabbit-review-helper", "sessionId": "cb2e607c-c758-415a-8b45-c49e4631906a", "version": "2.0.37", "gitBranch": "fix/comment-attribution-and-pagination", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_0128UrZy2Lq7Tdn7FYWLDgi7", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_013Cho8SURc4ESongaWZu4d7", "name": "AskUserQuestion", "input": {"question": "I need to understand your preferred installation approach to give you the best solution:\n\n1. **Installation method**: Do you want to install this as:\n - A **global tool** (using `uv tool install`) that's available system-wide?\n - A **library** that can be imported in other Python projects?\n - **Both** (installable as either)?\n\n2. **File naming**: The current script is `extract-coderabbit-feedback.py` but your pyproject.toml expects `coderabbit_review_extractor.py`. Would you prefer to:\n - **Rename** the file to match the configuration (recommended)\n - **Keep** the current name and update the configuration\n - Use a different name entirely?\n\n3. **Project structure**: Currently it's a single script. Do you want to:\n - **Keep** the single-file structure (simpler)\n - **Convert** to a proper package structure with `src/coderabbit_review_extractor/` (more maintainable for future growth)\n\n4. **Build backend**: Would you prefer:\n - **Hatchling** (UV's recommended, modern, zero-config)\n - **Keep setuptools** (what you currently have)\n\nThese choices will affect the specific configuration needed."}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 10, "cache_creation_input_tokens": 1561, "cache_read_input_tokens": 16340, "cache_creation": {"ephemeral_5m_input_tokens": 1561, "ephemeral_1h_input_tokens": 0}, "output_tokens": 685, "service_tier": "standard"}}, "requestId": "req_011CVDNqK6g58YEo1eBC6To3", "type": "assistant", "uuid": "e7ec4aaa-9676-4055-91eb-f2776361ec6f", "timestamp": "2025-11-17T11:24:30.683Z"}
diff --git a/dev-docs/messages/tools/Bash-tool_result.json b/dev-docs/messages/tools/Bash-tool_result.json
new file mode 100644
index 00000000..28037851
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_result.json
@@ -0,0 +1,23 @@
+{
+ "type": "user",
+ "sessionId": "9e953218-585f-4692-89df-9e0747a31c68",
+ "timestamp": "2025-10-03T23:59:15.607Z",
+ "uuid": "3367bd17-88e3-47f0-a32b-98b72d7ddebf",
+ "parentUuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_01T1SrbUgaSJkHWJd5outNgr",
+ "is_error": false,
+ "content": ""
+ }
+ ]
+ },
+ "toolUseResult": {
+ "stdout": "",
+ "stderr": ""
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/Bash-tool_result.jsonl b/dev-docs/messages/tools/Bash-tool_result.jsonl
new file mode 100644
index 00000000..c89be63c
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_result.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01T1SrbUgaSJkHWJd5outNgr", "type": "tool_result", "content": "", "is_error": false}]}, "uuid": "3367bd17-88e3-47f0-a32b-98b72d7ddebf", "timestamp": "2025-10-03T23:59:15.607Z", "toolUseResult": {"stdout": "", "stderr": "", "interrupted": false, "isImage": false}}
diff --git a/dev-docs/messages/tools/Bash-tool_result_error.json b/dev-docs/messages/tools/Bash-tool_result_error.json
new file mode 100644
index 00000000..7591e56d
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_result_error.json
@@ -0,0 +1,20 @@
+{
+ "type": "user",
+ "sessionId": "9e953218-585f-4692-89df-9e0747a31c68",
+ "timestamp": "2025-10-04T00:01:48.266Z",
+ "uuid": "2a6064fb-0f9b-4058-a9b9-faed1637dd55",
+ "parentUuid": "8fd655c9-4dc9-43e7-a333-063020015afd",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_01YKFv5mcsGBX463DAn2h9YD",
+ "is_error": true,
+ "content": "please add transformer.js too first"
+ }
+ ]
+ },
+ "toolUseResult": "Error: please add transformer.js too first"
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/Bash-tool_result_error.jsonl b/dev-docs/messages/tools/Bash-tool_result_error.jsonl
new file mode 100644
index 00000000..e9112de7
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_result_error.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "8fd655c9-4dc9-43e7-a333-063020015afd", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/online-llm-tokenizer", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "please add transformer.js too first", "is_error": true, "tool_use_id": "toolu_01YKFv5mcsGBX463DAn2h9YD"}]}, "uuid": "2a6064fb-0f9b-4058-a9b9-faed1637dd55", "timestamp": "2025-10-04T00:01:48.266Z", "toolUseResult": "Error: please add transformer.js too first"}
diff --git a/dev-docs/messages/tools/Bash-tool_use.json b/dev-docs/messages/tools/Bash-tool_use.json
new file mode 100644
index 00000000..8e60824a
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_use.json
@@ -0,0 +1,25 @@
+{
+ "type": "assistant",
+ "sessionId": "9e953218-585f-4692-89df-9e0747a31c68",
+ "timestamp": "2025-10-03T23:59:07.774Z",
+ "uuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a",
+ "parentUuid": "8103ed74-daa2-4599-a6f0-061c168667a5",
+ "isSidechain": false,
+ "message": {
+ "role": "assistant",
+ "type": "message",
+ "model": "claude-sonnet-4-5-20250929",
+ "id": "msg_01MUcHFgCTt4LYAEMUbGsZ9u",
+ "content": [
+ {
+ "type": "tool_use",
+ "id": "toolu_01T1SrbUgaSJkHWJd5outNgr",
+ "name": "Bash",
+ "input": {
+ "command": "cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.html /Users/dain/workspace/online-llm... [truncated]",
+ "description": "Copy tokenizer files to new repo"
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/Bash-tool_use.jsonl b/dev-docs/messages/tools/Bash-tool_use.jsonl
new file mode 100644
index 00000000..31c3f98d
--- /dev/null
+++ b/dev-docs/messages/tools/Bash-tool_use.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "8103ed74-daa2-4599-a6f0-061c168667a5", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "9e953218-585f-4692-89df-9e0747a31c68", "version": "2.0.5", "gitBranch": "main", "message": {"id": "msg_01MUcHFgCTt4LYAEMUbGsZ9u", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5-20250929", "content": [{"type": "tool_use", "id": "toolu_01T1SrbUgaSJkHWJd5outNgr", "name": "Bash", "input": {"command": "cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.html /Users/dain/workspace/online-llm-tokenizer/index.html && cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.css /Users/dain/workspace/online-llm-tokenizer/tokenizer.css && cp /Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js /Users/dain/workspace/online-llm-tokenizer/tokenizer.js", "description": "Copy tokenizer files to new repo"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 7, "cache_creation_input_tokens": 350, "cache_read_input_tokens": 25178, "cache_creation": {"ephemeral_5m_input_tokens": 350, "ephemeral_1h_input_tokens": 0}, "output_tokens": 26, "service_tier": "standard"}}, "requestId": "req_011CTmAzWHumhhBPD7N87B99", "type": "assistant", "uuid": "b71cdedf-849f-4f38-badc-75403cd3ee6a", "timestamp": "2025-10-03T23:59:07.774Z"}
diff --git a/dev-docs/messages/tools/BashOutput-tool_result.json b/dev-docs/messages/tools/BashOutput-tool_result.json
new file mode 100644
index 00000000..ed66afff
--- /dev/null
+++ b/dev-docs/messages/tools/BashOutput-tool_result.json
@@ -0,0 +1,23 @@
+{
+ "type": "user",
+ "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e",
+ "timestamp": "2025-11-18T00:03:27.238Z",
+ "uuid": "0aaed81b-3220-4424-ac7d-235802db656a",
+ "parentUuid": "be2b84d8-d747-48d3-adf0-143393870c88",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_01GvxiBWatZMFVNvxyDms7Ey",
+ "is_error": false,
+ "content": "running \n\n\n... [truncated]"
+ }
+ ]
+ },
+ "toolUseResult": {
+ "stdout": "> jssoundrecorder@0.1.0 dev /Users/dain/workspace/JSSoundRecorder\n> vite\n\n... [truncated]",
+ "stderr": ""
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/BashOutput-tool_result.jsonl b/dev-docs/messages/tools/BashOutput-tool_result.jsonl
new file mode 100644
index 00000000..cb68ffcd
--- /dev/null
+++ b/dev-docs/messages/tools/BashOutput-tool_result.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "be2b84d8-d747-48d3-adf0-143393870c88", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/JSSoundRecorder", "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", "version": "2.0.42", "gitBranch": "gh-pages", "type": "user", "message": {"role": "user", "content": [{"tool_use_id": "toolu_01GvxiBWatZMFVNvxyDms7Ey", "type": "tool_result", "content": "running \n\n\n> jssoundrecorder@0.1.0 dev /Users/dain/workspace/JSSoundRecorder\n> vite\n\n\n VITE v5.4.21 ready in 377 ms\n\n \u279c Local: http://localhost:5173/\n \u279c Network: use --host to expose\n \n\n2025-11-18T00:03:27.238Z "}]}, "uuid": "0aaed81b-3220-4424-ac7d-235802db656a", "timestamp": "2025-11-18T00:03:27.238Z", "toolUseResult": {"shellId": "dce0af", "command": "pnpm dev", "status": "running", "exitCode": null, "stdout": "> jssoundrecorder@0.1.0 dev /Users/dain/workspace/JSSoundRecorder\n> vite\n\n\n VITE v5.4.21 ready in 377 ms\n\n \u279c Local: http://localhost:5173/\n \u279c Network: use --host to expose", "stderr": "", "stdoutLines": 9, "stderrLines": 1, "timestamp": "2025-11-18T00:03:27.238Z"}}
diff --git a/dev-docs/messages/tools/BashOutput-tool_use.json b/dev-docs/messages/tools/BashOutput-tool_use.json
new file mode 100644
index 00000000..b48acc4c
--- /dev/null
+++ b/dev-docs/messages/tools/BashOutput-tool_use.json
@@ -0,0 +1,24 @@
+{
+ "type": "assistant",
+ "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e",
+ "timestamp": "2025-11-18T00:03:27.174Z",
+ "uuid": "be2b84d8-d747-48d3-adf0-143393870c88",
+ "parentUuid": "4682610f-dd5e-4c44-909b-22f1d86ff164",
+ "isSidechain": false,
+ "message": {
+ "role": "assistant",
+ "type": "message",
+ "model": "claude-sonnet-4-5-20250929",
+ "id": "msg_01EPx394bFyVKqqNVDKWqnct",
+ "content": [
+ {
+ "type": "tool_use",
+ "id": "toolu_01GvxiBWatZMFVNvxyDms7Ey",
+ "name": "BashOutput",
+ "input": {
+ "bash_id": "dce0af"
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/BashOutput-tool_use.jsonl b/dev-docs/messages/tools/BashOutput-tool_use.jsonl
new file mode 100644
index 00000000..456b32ce
--- /dev/null
+++ b/dev-docs/messages/tools/BashOutput-tool_use.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "4682610f-dd5e-4c44-909b-22f1d86ff164", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/JSSoundRecorder", "sessionId": "7acd37a8-2745-4b58-a8a9-46164b22ad9e", "version": "2.0.42", "gitBranch": "gh-pages", "message": {"model": "claude-sonnet-4-5-20250929", "id": "msg_01EPx394bFyVKqqNVDKWqnct", "type": "message", "role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01GvxiBWatZMFVNvxyDms7Ey", "name": "BashOutput", "input": {"bash_id": "dce0af"}}], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 8, "cache_creation_input_tokens": 286, "cache_read_input_tokens": 40733, "cache_creation": {"ephemeral_5m_input_tokens": 286, "ephemeral_1h_input_tokens": 0}, "output_tokens": 147, "service_tier": "standard"}}, "requestId": "req_011CVENim5vua4dLWAdhofCZ", "type": "assistant", "uuid": "be2b84d8-d747-48d3-adf0-143393870c88", "timestamp": "2025-11-18T00:03:27.174Z"}
diff --git a/dev-docs/messages/tools/Edit-tool_result.json b/dev-docs/messages/tools/Edit-tool_result.json
new file mode 100644
index 00000000..f07432d1
--- /dev/null
+++ b/dev-docs/messages/tools/Edit-tool_result.json
@@ -0,0 +1,20 @@
+{
+ "type": "user",
+ "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed",
+ "timestamp": "2025-09-29T17:08:56.317Z",
+ "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9",
+ "parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX",
+ "is_error": true,
+ "content": "File has not been read yet. Read it first before writing to it. "
+ }
+ ]
+ },
+ "toolUseResult": "Error: File has not been read yet. Read it first before writing to it."
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/Edit-tool_result.jsonl b/dev-docs/messages/tools/Edit-tool_result.jsonl
new file mode 100644
index 00000000..bf89e58c
--- /dev/null
+++ b/dev-docs/messages/tools/Edit-tool_result.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "File has not been read yet. Read it first before writing to it. ", "is_error": true, "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX"}]}, "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", "timestamp": "2025-09-29T17:08:56.317Z", "toolUseResult": "Error: File has not been read yet. Read it first before writing to it."}
diff --git a/dev-docs/messages/tools/Edit-tool_result_error.json b/dev-docs/messages/tools/Edit-tool_result_error.json
new file mode 100644
index 00000000..f07432d1
--- /dev/null
+++ b/dev-docs/messages/tools/Edit-tool_result_error.json
@@ -0,0 +1,20 @@
+{
+ "type": "user",
+ "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed",
+ "timestamp": "2025-09-29T17:08:56.317Z",
+ "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9",
+ "parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX",
+ "is_error": true,
+ "content": "File has not been read yet. Read it first before writing to it. "
+ }
+ ]
+ },
+ "toolUseResult": "Error: File has not been read yet. Read it first before writing to it."
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/Edit-tool_result_error.jsonl b/dev-docs/messages/tools/Edit-tool_result_error.jsonl
new file mode 100644
index 00000000..bf89e58c
--- /dev/null
+++ b/dev-docs/messages/tools/Edit-tool_result_error.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "File has not been read yet. Read it first before writing to it. ", "is_error": true, "tool_use_id": "toolu_01LsK8An4morbFYkB3fejkoX"}]}, "uuid": "642ea10e-e0d8-43f4-9c26-ebce0828a8b9", "timestamp": "2025-09-29T17:08:56.317Z", "toolUseResult": "Error: File has not been read yet. Read it first before writing to it."}
diff --git a/dev-docs/messages/tools/Edit-tool_use.json b/dev-docs/messages/tools/Edit-tool_use.json
new file mode 100644
index 00000000..0807c418
--- /dev/null
+++ b/dev-docs/messages/tools/Edit-tool_use.json
@@ -0,0 +1,26 @@
+{
+ "type": "assistant",
+ "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed",
+ "timestamp": "2025-09-29T17:08:56.225Z",
+ "uuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f",
+ "parentUuid": "eddc6f0f-e83b-4371-aaea-48617f80f642",
+ "isSidechain": false,
+ "message": {
+ "role": "assistant",
+ "type": "message",
+ "model": "claude-sonnet-4-20250514",
+ "id": "msg_01GpixxQhWDdiAXnh7Y7KvRp",
+ "content": [
+ {
+ "type": "tool_use",
+ "id": "toolu_01LsK8An4morbFYkB3fejkoX",
+ "name": "Edit",
+ "input": {
+ "file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js",
+ "old_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n... [truncated]",
+ "new_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n... [truncated]"
+ }
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/Edit-tool_use.jsonl b/dev-docs/messages/tools/Edit-tool_use.jsonl
new file mode 100644
index 00000000..4f5765bb
--- /dev/null
+++ b/dev-docs/messages/tools/Edit-tool_use.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "eddc6f0f-e83b-4371-aaea-48617f80f642", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "message": {"id": "msg_01GpixxQhWDdiAXnh7Y7KvRp", "type": "message", "role": "assistant", "model": "claude-sonnet-4-20250514", "content": [{"type": "tool_use", "id": "toolu_01LsK8An4morbFYkB3fejkoX", "name": "Edit", "input": {"file_path": "/Users/dain/workspace/danieldemmel.me-next/public/tokenizer.js", "old_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n text === '\\n'\n ? '
'\n : `${token}${text.replace(/&/g, '&').replace(//g, '>')}`)\n}", "new_string": "const renderTokenAndText = (acc, { token, text }, index) => {\n return (acc +=\n text === '\\n'\n ? '
'\n : `${text.replace(/&/g, '&').replace(//g, '>')} `)\n}"}}], "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 313, "cache_read_input_tokens": 22329, "cache_creation": {"ephemeral_5m_input_tokens": 313, "ephemeral_1h_input_tokens": 0}, "output_tokens": 1, "service_tier": "standard"}}, "requestId": "req_011CTd4URvudKaXc6Y7Nnfan", "type": "assistant", "uuid": "9112bb66-ff4b-499f-bef8-03fc2317a56f", "timestamp": "2025-09-29T17:08:56.225Z"}
diff --git a/dev-docs/messages/tools/ExitPlanMode-tool_result.json b/dev-docs/messages/tools/ExitPlanMode-tool_result.json
new file mode 100644
index 00000000..e8b52c75
--- /dev/null
+++ b/dev-docs/messages/tools/ExitPlanMode-tool_result.json
@@ -0,0 +1,20 @@
+{
+ "type": "user",
+ "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed",
+ "timestamp": "2025-09-29T17:08:41.320Z",
+ "uuid": "83bb4f7b-1c10-4297-869b-d8553691adee",
+ "parentUuid": "67b1db15-73a4-4de3-8a6e-3c27eff6f5bb",
+ "isSidechain": false,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_0173799ePMBxKdX8hsuevgm7",
+ "is_error": false,
+ "content": "User has approved your plan. You can now start coding. Start with updating your todo list if applicable"
+ }
+ ]
+ },
+ "toolUseResult": {}
+}
\ No newline at end of file
diff --git a/dev-docs/messages/tools/ExitPlanMode-tool_result.jsonl b/dev-docs/messages/tools/ExitPlanMode-tool_result.jsonl
new file mode 100644
index 00000000..6073b92d
--- /dev/null
+++ b/dev-docs/messages/tools/ExitPlanMode-tool_result.jsonl
@@ -0,0 +1 @@
+{"parentUuid": "67b1db15-73a4-4de3-8a6e-3c27eff6f5bb", "isSidechain": false, "userType": "external", "cwd": "/Users/dain/workspace/danieldemmel.me-next", "sessionId": "b25638d7-b104-4f06-a797-70ac33d069ed", "version": "1.0.128", "gitBranch": "main", "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "User has approved your plan. You can now start coding. Start with updating your todo list if applicable", "tool_use_id": "toolu_0173799ePMBxKdX8hsuevgm7"}]}, "uuid": "83bb4f7b-1c10-4297-869b-d8553691adee", "timestamp": "2025-09-29T17:08:41.320Z", "toolUseResult": {"plan": "## Plan to Fix Ruby Element Support for Chrome\n\nI'll rewrite the tokenizer display to use proper HTML ruby elements instead of CSS display properties for better browser compatibility:\n\n### Changes to make:\n\n1. **Update JavaScript rendering** (`/public/tokenizer.js`):\n - Modify the `renderTokenAndText` function to generate proper ruby HTML structure\n - Each token/text pair will use `` with `` for text and `