Skip to content
34 changes: 28 additions & 6 deletions claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
should_use_as_session_starter,
create_session_preview,
extract_working_directories,
get_warmup_session_ids,
)
from .cache import CacheManager, SessionCacheData, get_library_version
from .parser import (
Expand Down Expand Up @@ -303,10 +304,19 @@ def _update_cache_with_session_data(
usage.cache_read_input_tokens
)

# Update cache with session data
# Filter out warmup-only and empty sessions before caching
warmup_session_ids = get_warmup_session_ids(messages)
sessions_cache_data = {
sid: data
for sid, data in sessions_cache_data.items()
if sid not in warmup_session_ids
and data.first_user_message # Filter empty sessions (agent-only)
}

# Update cache with filtered session data
cache_manager.update_session_cache(sessions_cache_data)

# Update cache with working directories
# Update cache with working directories (from filtered sessions)
cache_manager.update_working_directories(
extract_working_directories(list(sessions_cache_data.values()))
)
Expand Down Expand Up @@ -342,6 +352,9 @@ def _collect_project_sessions(messages: List[TranscriptEntry]) -> List[Dict[str,
"""Collect session data for project index navigation."""
from .parser import extract_text_content

# Pre-compute warmup session IDs to filter them out
warmup_session_ids = get_warmup_session_ids(messages)

# Pre-process to find and attach session summaries
# This matches the logic from renderer.py generate_html() exactly
session_summaries: Dict[str, str] = {}
Expand Down Expand Up @@ -375,14 +388,14 @@ def _collect_project_sessions(messages: List[TranscriptEntry]) -> List[Dict[str,
):
session_summaries[uuid_to_session_backup[leaf_uuid]] = message.summary

# Group messages by session
# Group messages by session (excluding warmup-only sessions)
sessions: Dict[str, Dict[str, Any]] = {}
for message in messages:
if hasattr(message, "sessionId") and not isinstance(
message, SummaryTranscriptEntry
):
session_id = getattr(message, "sessionId", "")
if not session_id:
if not session_id or session_id in warmup_session_ids:
continue

if session_id not in sessions:
Expand Down Expand Up @@ -439,6 +452,9 @@ def _collect_project_sessions(messages: List[TranscriptEntry]) -> List[Dict[str,
if session_data["first_user_message"] != ""
else "[No user message found in session.]",
}
# Skip sessions with no user messages (empty sessions / agent-only)
if session_data["first_user_message"] == "":
continue
session_list.append(session_dict)

# Sort by first timestamp (ascending order, oldest first like transcript page)
Expand All @@ -456,12 +472,15 @@ def _generate_individual_session_files(
cache_was_updated: bool = False,
) -> None:
"""Generate individual HTML files for each session."""
# Find all unique session IDs
# Pre-compute warmup sessions to exclude them
warmup_session_ids = get_warmup_session_ids(messages)

# Find all unique session IDs (excluding warmup sessions)
session_ids: set[str] = set()
for message in messages:
if hasattr(message, "sessionId"):
session_id: str = getattr(message, "sessionId")
if session_id:
if session_id and session_id not in warmup_session_ids:
session_ids.add(session_id)

# Get session data from cache for better titles
Expand Down Expand Up @@ -630,6 +649,9 @@ def process_projects_hierarchy(
or "[No user message found in session.]",
}
for session_data in cached_project_data.sessions.values()
# Filter out warmup-only and empty sessions (agent-only)
if session_data.first_user_message
and session_data.first_user_message != "Warmup"
],
}
)
Expand Down
10 changes: 8 additions & 2 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,17 @@ class SummaryTranscriptEntry(BaseModel):


class SystemTranscriptEntry(BaseTranscriptEntry):
"""System messages like warnings, notifications, etc."""
"""System messages like warnings, notifications, hook summaries, etc."""

type: Literal["system"]
content: str
content: Optional[str] = None
subtype: Optional[str] = None # e.g., "stop_hook_summary"
level: Optional[str] = None # e.g., "warning", "info", "error"
# Hook summary fields (for subtype="stop_hook_summary")
hasOutput: Optional[bool] = None
hookErrors: Optional[List[str]] = None
hookInfos: Optional[List[Dict[str, Any]]] = None
preventedContinuation: Optional[bool] = None


class QueueOperationTranscriptEntry(BaseModel):
Expand Down
198 changes: 154 additions & 44 deletions claude_code_log/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2030,6 +2030,48 @@ 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.

Shows a compact summary with expandable hook commands and error output.
"""
# 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 = '<div class="hook-commands">'
for cmd in commands:
# Truncate very long commands
display_cmd = cmd if len(cmd) <= 100 else cmd[:97] + "..."
command_html += f"<code>{html.escape(display_cmd)}</code>"
command_html += "</div>"

# Build the error output section
error_html = ""
if message.hookErrors:
error_html = '<div class="hook-errors">'
for err in message.hookErrors:
# Convert ANSI codes in error output
formatted_err = _convert_ansi_to_html(err)
error_html += f'<pre class="hook-error">{formatted_err}</pre>'
error_html += "</div>"

return f"""<details class="hook-summary">
<summary><strong>{summary_icon}</strong> {summary_text}</summary>
<div class="hook-details">
{command_html}
{error_html}
</div>
</details>"""


def _convert_ansi_to_html(text: str) -> str:
"""Convert ANSI escape codes to HTML spans with CSS classes.

Expand Down Expand Up @@ -2394,6 +2436,7 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str, str]:
import re

css_class = "bash-output"
COLLAPSE_THRESHOLD = 10 # Collapse if more than this many lines

stdout_match = re.search(
r"<bash-stdout>(.*?)</bash-stdout>",
Expand All @@ -2406,21 +2449,57 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str, str]:
re.DOTALL,
)

output_parts: List[str] = []
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)
output_parts.append(f"<pre class='bash-stdout'>{escaped_stdout}</pre>")
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)
output_parts.append(f"<pre class='bash-stderr'>{escaped_stderr}</pre>")
stderr_lines = stderr_content.count("\n") + 1
total_lines += stderr_lines
output_parts.append(
("stderr", escaped_stderr, stderr_lines, stderr_content)
)

if output_parts:
content_html = "".join(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"<pre class='{css_name}'>{escaped_content}</pre>")

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"""<details class='collapsible-code'>
<summary>
<span class='line-count'>{total_lines} lines</span>
<pre class='preview-content bash-stdout'>{preview_html}</pre>
</summary>
<div class='code-full'>{full_html}</div>
</details>"""
else:
content_html = full_html
else:
# Empty output
content_html = (
Expand Down Expand Up @@ -2927,13 +3006,25 @@ def generate_html(
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] = {}
Expand Down Expand Up @@ -2985,6 +3076,10 @@ def generate_html(
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"]
Expand Down Expand Up @@ -3323,47 +3418,62 @@ def _process_messages_loop(
timestamp = getattr(message, "timestamp", "")
formatted_timestamp = format_timestamp(timestamp) if timestamp else ""

# Extract command name if present
command_name_match = re.search(
r"<command-name>(.*?)</command-name>", message.content, re.DOTALL
)
# Also check for command output (child of user command)
command_output_match = re.search(
r"<local-command-stdout>(.*?)</local-command-stdout>",
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"<code>{html.escape(command_name)}</code>"
content_html = f"<strong>{level_icon}</strong> {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"<strong>{level_icon}</strong> {html_content}"
# 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:
# Process ANSI codes in system messages (they may contain command output)
html_content = _convert_ansi_to_html(message.content)
content_html = f"<strong>{level_icon}</strong> {html_content}"
# Extract command name if present
command_name_match = re.search(
r"<command-name>(.*?)</command-name>", message.content, re.DOTALL
)
# Also check for command output (child of user command)
command_output_match = re.search(
r"<local-command-stdout>(.*?)</local-command-stdout>",
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"<code>{html.escape(command_name)}</code>"
content_html = f"<strong>{level_icon}</strong> {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"<strong>{level_icon}</strong> {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"<strong>{level_icon}</strong> {html_content}"

# Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy)
parent_uuid = getattr(message, "parentUuid", None)
Expand Down
Loading
Loading