Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions claude_code_log/html/assistant_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,34 @@ def format_assistant_text_content(
) -> str:
"""Format assistant text content as HTML.

When `items` is set, iterates through the content items preserving order:
- TextContent: Rendered as markdown with collapsible support
- ImageContent: Rendered as inline <img> tag with base64 data URL

Falls back to legacy text-only behavior when `items` is None.

Args:
content: AssistantTextContent with the text to render
content: AssistantTextContent with text/items 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,
)
parts: list[str] = []
for item in content.items:
if isinstance(item, ImageContent):
parts.append(format_image_content(item))
else: # TextContent
if item.text.strip():
text_html = render_markdown_collapsible(
item.text,
"assistant-text",
line_threshold=line_threshold,
preview_line_count=preview_line_count,
)
parts.append(text_html)
return "\n".join(parts)
Comment on lines +34 to +61
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring claims fallback behavior that isn't implemented.

The docstring at line 38 states "Falls back to legacy text-only behavior when items is None", but the code unconditionally iterates over content.items without checking for None. If items can be None (as the docstring implies), this would raise a TypeError.

Either:

  1. Remove the fallback claim from the docstring if items is always populated, or
  2. Add the fallback logic if backward compatibility is needed.

Additionally, the else branch at line 52 assumes any non-ImageContent item is TextContent, which could cause AttributeError if an unexpected type is encountered. Consider adding an explicit type check.

🔎 Proposed fix with explicit type handling
 from ..models import (
     AssistantTextContent,
     ImageContent,
+    TextContent,
     ThinkingContentModel,
     UnknownContent,
 )
     parts: list[str] = []
     for item in content.items:
         if isinstance(item, ImageContent):
             parts.append(format_image_content(item))
-        else:  # TextContent
+        elif isinstance(item, TextContent):
             if item.text.strip():
                 text_html = render_markdown_collapsible(
                     item.text,
                     "assistant-text",
                     line_threshold=line_threshold,
                     preview_line_count=preview_line_count,
                 )
                 parts.append(text_html)
+        # Other types are silently skipped
     return "\n".join(parts)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In claude_code_log/html/assistant_formatters.py around lines 34 to 61, the
docstring claims a fallback when content.items is None but the code
unconditionally iterates content.items and assumes non-ImageContent are
TextContent; update the implementation to match the docstring by checking if
content.items is None and fall back to the legacy text-only rendering path
(render content.text via render_markdown_collapsible), or if you prefer to keep
items always present remove the fallback claim from the docstring; additionally
replace the unguarded else with an explicit isinstance(item, TextContent) branch
(render it) and otherwise skip/log unexpected item types to avoid
AttributeError.



def format_thinking_content(
Expand Down
122 changes: 47 additions & 75 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""HTML renderer implementation for Claude Code transcripts."""

from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Tuple
from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple

from ..cache import get_library_version
from ..models import (
Expand All @@ -12,7 +13,6 @@
CompactedSummaryContent,
DedupNoticeContent,
HookSummaryContent,
ImageContent,
SessionHeaderContent,
SlashCommandContent,
SystemContent,
Expand All @@ -23,6 +23,7 @@
TranscriptEntry,
UnknownContent,
UserMemoryContent,
UserSlashCommandContent,
UserTextContent,
)
from ..renderer import (
Expand All @@ -46,11 +47,11 @@
format_compacted_summary_content,
format_slash_command_content,
format_user_memory_content,
format_user_slash_command_content,
format_user_text_model_content,
)
from .assistant_formatters import (
format_assistant_text_content,
format_image_content,
format_thinking_content,
format_unknown_content,
)
Expand Down Expand Up @@ -93,79 +94,50 @@ def check_html_version(html_file_path: Path) -> Optional[str]:
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.
def _build_dispatcher(self) -> dict[type, Callable[..., str]]:
"""Build content type to HTML formatter mapping.

Args:
message: TemplateMessage with content model to format

Returns:
HTML string for the message content, or empty string if no content
Maps MessageContent subclasses to their HTML formatting functions.
Handlers receive the content directly (not the full TemplateMessage).
The cast to the correct type happens in format_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 ""
return {
# System content types
SystemContent: format_system_content,
HookSummaryContent: format_hook_summary_content,
SessionHeaderContent: format_session_header_content,
DedupNoticeContent: format_dedup_notice_content,
# User content types
SlashCommandContent: format_slash_command_content,
CommandOutputContent: format_command_output_content,
BashInputContent: format_bash_input_content,
BashOutputContent: format_bash_output_content,
CompactedSummaryContent: format_compacted_summary_content,
UserMemoryContent: format_user_memory_content,
UserSlashCommandContent: format_user_slash_command_content,
UserTextContent: format_user_text_model_content,
# Assistant content types
ThinkingContentModel: partial(format_thinking_content, line_threshold=10),
AssistantTextContent: format_assistant_text_content,
UnknownContent: format_unknown_content,
# Tool content types
ToolUseContent: format_tool_use_content,
ToolResultContentModel: self._format_tool_result_content,
}

def _format_tool_result_content(self, content: ToolResultContentModel) -> str:
"""Format ToolResultContentModel with associated tool context."""
tool_result = ToolResultContent(
type="tool_result",
tool_use_id=content.tool_use_id,
content=content.content,
is_error=content.is_error,
)
return format_tool_result_content(
tool_result,
content.file_path,
content.tool_name,
)

def _flatten_preorder(
self, roots: list[TemplateMessage]
Expand All @@ -184,7 +156,7 @@ def _flatten_preorder(
flat: list[Tuple[TemplateMessage, str]] = []

def visit(msg: TemplateMessage) -> None:
html = self._format_message_content(msg)
html = self.format_content(msg)
flat.append((msg, html))
for child in msg.children:
visit(child)
Expand Down
68 changes: 45 additions & 23 deletions claude_code_log/html/user_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
IdeNotificationContent,
IdeOpenedFile,
IdeSelection,
ImageContent,
SlashCommandContent,
UserMemoryContent,
UserSlashCommandContent,
UserTextContent,
)
from .tool_formatters import render_params_table
Expand Down Expand Up @@ -193,37 +195,37 @@ 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.
memory input markers, and inline images.

When `items` is set, iterates through the content items preserving order:
- TextContent: Rendered as preformatted text
- ImageContent: Rendered as inline <img> tag with base64 data URL
- IdeNotificationContent: Rendered as IDE notification blocks

Falls back to legacy text-only behavior when `items` is None.

Args:
content: UserTextContent with text and optional flags/notifications
content: UserTextContent with text/items and optional flags/notifications

Returns:
HTML string combining IDE notifications and main text content
HTML string combining all content items
"""
parts: list[str] = []
# Import here to avoid circular dependency
from .assistant_formatters import format_image_content

# Add IDE notifications first if present
if content.ide_notifications:
notifications = format_ide_notification_content(content.ide_notifications)
parts.extend(notifications)
parts: list[str] = []

# 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)
for item in content.items:
if isinstance(item, IdeNotificationContent):
notifications = format_ide_notification_content(item)
parts.extend(notifications)
elif isinstance(item, ImageContent):
parts.append(format_image_content(item))
else: # TextContent
# Regular user text as preformatted
if item.text.strip():
parts.append(format_user_text_content(item.text))

parts.append(text_html)
return "\n".join(parts)


Expand Down Expand Up @@ -263,6 +265,26 @@ def format_user_memory_content(content: UserMemoryContent) -> str:
return f"<pre>{escaped_text}</pre>"


def format_user_slash_command_content(content: UserSlashCommandContent) -> str:
"""Format slash command expanded prompt (isMeta) as HTML.

These are LLM-generated instruction text from slash commands,
rendered as collapsible markdown.

Args:
content: UserSlashCommandContent with markdown text

Returns:
HTML string with collapsible markdown rendering
"""
return render_markdown_collapsible(
content.text,
"slash-command",
line_threshold=30,
preview_line_count=10,
)
Comment on lines +268 to +285
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

New function format_user_slash_command_content is not exported in __all__.

The function is defined but not included in the __all__ list at lines 374-385. If this function is intended to be part of the public API (as indicated by the AI summary), it should be added to the exports.

🔎 Proposed fix
 __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",
+    "format_user_slash_command_content",
 ]
🤖 Prompt for AI Agents
In claude_code_log/html/user_formatters.py around lines 268 and the export block
at lines 374-385, the new function format_user_slash_command_content is defined
but not exported; add "format_user_slash_command_content" to the module's
__all__ list in the export block so the function is part of the public API,
placing it alongside the other formatter names and keeping the list
alphabetized/consistent with the existing style.



def _format_opened_file(opened_file: IdeOpenedFile) -> str:
"""Format a single IDE opened file notification as HTML."""
escaped_content = escape_html(opened_file.content)
Expand Down
Loading
Loading