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
4 changes: 1 addition & 3 deletions claude_code_log/html/assistant_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,10 @@ def format_assistant_text_content(
) -> str:
"""Format assistant text content as HTML.

When `items` is set, iterates through the content items preserving order:
Iterates through 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 text/items to render
line_threshold: Number of lines before content becomes collapsible
Expand Down
2 changes: 1 addition & 1 deletion claude_code_log/html/templates/transcript.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ <h3>🔍 Search & Filter</h3>
</div>
{% else %}
{%- set msg_css_class = css_class_from_message(message) %}
{% set markdown = message.type in ['assistant', 'thinking'] or message.modifiers.is_compacted %}
{% set markdown = message.has_markdown %}
<div class='message {{ msg_css_class }}{% if message.is_paired %} {{ message.pair_role }}{% endif %}{% for ancestor_id in message.ancestry %} {{ ancestor_id }}{% endfor %}' data-message-id='{{ message.message_id }}' id='msg-{{ message.message_id }}'>
<div class='header'>
{% set msg_emoji = get_message_emoji(message) -%}
Expand Down
114 changes: 89 additions & 25 deletions claude_code_log/html/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,50 +22,114 @@
from jinja2 import Environment, FileSystemLoader, select_autoescape

from .renderer_code import highlight_code_with_pygments, truncate_highlighted_preview
from ..models import (
AssistantTextContent,
BashInputContent,
BashOutputContent,
CommandOutputContent,
CompactedSummaryContent,
HookSummaryContent,
MessageContent,
SessionHeaderContent,
SlashCommandContent,
SystemContent,
ThinkingContentModel,
ToolResultContentModel,
ToolUseContent,
UnknownContent,
UserMemoryContent,
UserSlashCommandContent,
UserSteeringContent,
UserTextContent,
)
from ..renderer_timings import timing_stat

if TYPE_CHECKING:
from ..renderer import TemplateMessage


# -- CSS Class Registry -------------------------------------------------------
# Maps content types to their CSS classes.
# The first class is typically the base type (user, assistant, system, etc.),
# followed by any static modifiers.

CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = {
# System message types
SystemContent: ["system"], # level added dynamically
HookSummaryContent: ["system", "system-hook"],
# User message types
UserTextContent: ["user"],
UserSteeringContent: ["user", "steering"],
SlashCommandContent: ["user", "slash-command"],
UserSlashCommandContent: ["user", "slash-command"],
UserMemoryContent: ["user"],
CompactedSummaryContent: ["user", "compacted"],
CommandOutputContent: ["user", "command-output"],
# Assistant message types
AssistantTextContent: ["assistant"],
# Tool message types
ToolUseContent: ["tool_use"],
ToolResultContentModel: ["tool_result"], # error added dynamically
# Other message types
ThinkingContentModel: ["thinking"],
SessionHeaderContent: ["session_header"],
BashInputContent: ["bash-input"],
BashOutputContent: ["bash-output"],
UnknownContent: ["unknown"],
}


def _get_css_classes_from_content(content: MessageContent) -> list[str]:
"""Get CSS classes from content type using the registry.

Walks the MRO to find a matching registry entry, then adds
any dynamic modifiers based on content attributes.
"""
for cls in type(content).__mro__:
if not issubclass(cls, MessageContent):
continue
if classes := CSS_CLASS_REGISTRY.get(cls):
result = list(classes)
# Dynamic modifiers based on content attributes
if isinstance(content, SystemContent):
result.append(f"system-{content.level}")
elif isinstance(content, ToolResultContentModel) and content.is_error:
result.append("error")
return result
return []


# -- 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.
Uses CSS_CLASS_REGISTRY to derive classes from content type,
with fallback to msg.type for messages without registered content.

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")
1. Message type (from content type or msg.type fallback)
2. Content-derived modifiers (e.g., slash-command, compacted, error)
3. Cross-cutting modifier flags: steering, sidechain

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:
# Get base classes and content-derived modifiers from content type
if msg.content:
parts = _get_css_classes_from_content(msg.content)
if not parts:
parts = [msg.type] # Fallback if content type not in registry
else:
parts = [msg.type]

# Cross-cutting modifier flags (not derivable from content type alone)
if msg.is_sidechain:
parts.append("sidechain")
if mods.system_level:
parts.append(f"system-{mods.system_level}")

return " ".join(parts)

Expand All @@ -85,21 +149,21 @@ def get_message_emoji(msg: "TemplateMessage") -> str:
return "📋"
elif msg_type == "user":
# Command output has no emoji (neutral - can be from built-in or user command)
if msg.modifiers.is_command_output:
if isinstance(msg.content, CommandOutputContent):
return ""
return "🤷"
elif msg_type == "bash-input":
return "💻"
elif msg_type == "assistant":
if msg.modifiers.is_sidechain:
if msg.is_sidechain:
return "🔗"
return "🤖"
elif msg_type == "system":
return "⚙️"
elif msg_type == "tool_use":
return "🛠️"
elif msg_type == "tool_result":
if msg.modifiers.is_error:
if isinstance(msg.content, ToolResultContentModel) and msg.content.is_error:
return "🚨"
return "🧰"
elif msg_type == "thinking":
Expand Down
93 changes: 15 additions & 78 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
"""Pydantic models for Claude Code transcript JSON structures.

Enhanced to leverage official Anthropic types where beneficial.
"""
"""Pydantic models for Claude Code transcript JSON structures."""

from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Union, Optional, Literal

from anthropic.types import Message as AnthropicMessage
from anthropic.types import StopReason
from anthropic.types import Usage as AnthropicUsage
from pydantic import BaseModel, PrivateAttr


Expand Down Expand Up @@ -51,28 +45,6 @@ class MessageType(str, Enum):
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
# =============================================================================
Expand Down Expand Up @@ -323,6 +295,17 @@ class UserTextContent(MessageContent):
] = field(default_factory=list)


@dataclass
class UserSteeringContent(UserTextContent):
"""Content for user steering prompts (queue-operation "remove").

These are user messages that steer the conversation by removing
items from the queue. Inherits from UserTextContent.
"""

pass


# =============================================================================
# Assistant Message Content Models
# =============================================================================
Expand Down Expand Up @@ -699,7 +682,7 @@ class ExitPlanModeInput(BaseModel):


class UsageInfo(BaseModel):
"""Token usage information that extends Anthropic's Usage type to handle optional fields."""
"""Token usage information for tracking API consumption."""

input_tokens: Optional[int] = None
cache_creation_input_tokens: Optional[int] = None
Expand All @@ -708,33 +691,6 @@ class UsageInfo(BaseModel):
service_tier: Optional[str] = None
server_tool_use: Optional[dict[str, Any]] = None

def to_anthropic_usage(self) -> Optional[AnthropicUsage]:
"""Convert to Anthropic Usage type if both required fields are present."""
if self.input_tokens is not None and self.output_tokens is not None:
return AnthropicUsage(
input_tokens=self.input_tokens,
output_tokens=self.output_tokens,
cache_creation_input_tokens=self.cache_creation_input_tokens,
cache_read_input_tokens=self.cache_read_input_tokens,
service_tier=self.service_tier, # type: ignore
server_tool_use=self.server_tool_use, # type: ignore
)
return None

@classmethod
def from_anthropic_usage(cls, usage: AnthropicUsage) -> "UsageInfo":
"""Create UsageInfo from Anthropic Usage."""
return cls(
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cache_creation_input_tokens=usage.cache_creation_input_tokens,
cache_read_input_tokens=usage.cache_read_input_tokens,
service_tier=usage.service_tier,
server_tool_use=usage.server_tool_use.model_dump()
if usage.server_tool_use
else None,
)


class ToolUseContent(BaseModel, MessageContent):
type: Literal["tool_use"]
Expand Down Expand Up @@ -793,36 +749,17 @@ class UserMessage(BaseModel):


class AssistantMessage(BaseModel):
"""Assistant message model compatible with Anthropic's Message type."""
"""Assistant message model."""

id: str
type: Literal["message"]
role: Literal["assistant"]
model: str
content: list[ContentItem]
stop_reason: Optional[StopReason] = None
stop_reason: Optional[str] = None
stop_sequence: Optional[str] = None
usage: Optional[UsageInfo] = None

@classmethod
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,
type=anthropic_msg.type,
role=anthropic_msg.role,
model=anthropic_msg.model,
content=list(anthropic_msg.content), # type: ignore[arg-type]
stop_reason=anthropic_msg.stop_reason,
stop_sequence=anthropic_msg.stop_sequence,
usage=normalize_usage_info(anthropic_msg.usage),
)


# Tool result type - flexible to accept various result formats from JSONL
# The specific parsing/formatting happens in tool_formatters.py using
Expand Down
Loading
Loading