From 796d51415c7b2a827e1c4f257e012335df042737 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 20 Dec 2025 16:17:06 +0100 Subject: [PATCH 01/57] Rename *Content to *Message and add ToolOutput/ToolUseMessage types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete naming refactoring (RENAME_CONTENT_TO_MESSAGE.md): Phase 1: Rename Pydantic transcript models - UserMessage → UserMessageModel - AssistantMessage → AssistantMessageModel Phase 2: Rename all MessageContent subclasses from *Content to *Message - UserTextContent → UserTextMessage, AssistantTextContent → AssistantTextMessage - ToolResultContentModel → ToolResultMessage, ThinkingContentModel → ThinkingMessage - SlashCommandContent → SlashCommandMessage, etc. Phase 3: Create ToolOutput/ToolUseMessage types - Add ToolOutput union (ReadOutput, EditOutput, ToolResultContent) - Add ToolUseMessage dataclass wrapping ToolInput with metadata - Update ToolResultMessage to use output: ToolOutput field - Fix Output classes to be plain dataclasses (data containers, not MessageContent) Phase 4: Update CSS_CLASS_REGISTRY with new type names Updated all formatters, renderer, parser, and tests for new naming. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/__init__.py | 44 ++--- claude_code_log/html/assistant_formatters.py | 24 +-- claude_code_log/html/renderer.py | 101 ++++++---- claude_code_log/html/system_formatters.py | 32 +-- claude_code_log/html/tool_formatters.py | 57 ++++++ claude_code_log/html/user_formatters.py | 58 +++--- claude_code_log/html/utils.py | 74 +++---- claude_code_log/models.py | 108 ++++++---- claude_code_log/parser.py | 70 +++---- claude_code_log/renderer.py | 94 ++++----- dev-docs/RENAME_CONTENT_TO_MESSAGE.md | 173 ++++++++++++++++ dev-docs/messages.md | 196 ++++++++++++------- test/test_cache.py | 12 +- test/test_ide_tags.py | 10 +- test/test_toggle_functionality.py | 6 +- test/test_user_renderer.py | 34 ++-- test/test_utils.py | 10 +- test/test_version_deduplication.py | 26 +-- 18 files changed, 723 insertions(+), 406 deletions(-) create mode 100644 dev-docs/RENAME_CONTENT_TO_MESSAGE.md diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py index ef847ef9..50cb738f 100644 --- a/claude_code_log/html/__init__.py +++ b/claude_code_log/html/__init__.py @@ -43,21 +43,21 @@ format_system_content, ) from ..models import ( - AssistantTextContent, - BashInputContent, - BashOutputContent, - CommandOutputContent, - CompactedSummaryContent, - DedupNoticeContent, + AssistantTextMessage, + BashInputMessage, + BashOutputMessage, + CommandOutputMessage, + CompactedSummaryMessage, + DedupNoticeMessage, IdeDiagnostic, IdeNotificationContent, IdeOpenedFile, IdeSelection, - SessionHeaderContent, - SlashCommandContent, - ThinkingContentModel, - UserMemoryContent, - UserTextContent, + SessionHeaderMessage, + SlashCommandMessage, + ThinkingMessage, + UserMemoryMessage, + UserTextMessage, ) from ..parser import ( parse_bash_input, @@ -122,16 +122,16 @@ "format_session_header_content", "format_system_content", # system content models - "DedupNoticeContent", - "SessionHeaderContent", + "DedupNoticeMessage", + "SessionHeaderMessage", # user_formatters (content models) - "SlashCommandContent", - "CommandOutputContent", - "BashInputContent", - "BashOutputContent", - "CompactedSummaryContent", - "UserMemoryContent", - "UserTextContent", + "SlashCommandMessage", + "CommandOutputMessage", + "BashInputMessage", + "BashOutputMessage", + "CompactedSummaryMessage", + "UserMemoryMessage", + "UserTextMessage", "IdeNotificationContent", "IdeOpenedFile", "IdeSelection", @@ -153,8 +153,8 @@ "parse_bash_output", "parse_ide_notifications", # assistant_formatters (content models) - "AssistantTextContent", - "ThinkingContentModel", + "AssistantTextMessage", + "ThinkingMessage", # assistant_formatters (formatting) "format_assistant_text_content", "format_thinking_content", diff --git a/claude_code_log/html/assistant_formatters.py b/claude_code_log/html/assistant_formatters.py index cbfe41d0..9bd725f1 100644 --- a/claude_code_log/html/assistant_formatters.py +++ b/claude_code_log/html/assistant_formatters.py @@ -2,19 +2,19 @@ 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 +- system_formatters.py: SystemMessage, HookSummaryMessage +- user_formatters.py: SlashCommandMessage, CommandOutputMessage, BashInputMessage +- assistant_formatters.py: AssistantTextMessage, ThinkingMessage, ImageContent - tool_formatters.py: tool use/result content Content models are defined in models.py, this module only handles formatting. """ from ..models import ( - AssistantTextContent, + AssistantTextMessage, ImageContent, - ThinkingContentModel, - UnknownContent, + ThinkingMessage, + UnknownMessage, ) from .utils import escape_html, render_markdown_collapsible @@ -25,7 +25,7 @@ def format_assistant_text_content( - content: AssistantTextContent, + content: AssistantTextMessage, line_threshold: int = 30, preview_line_count: int = 10, ) -> str: @@ -36,7 +36,7 @@ def format_assistant_text_content( - ImageContent: Rendered as inline tag with base64 data URL Args: - content: AssistantTextContent with text/items to render + content: AssistantTextMessage 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 @@ -60,14 +60,14 @@ def format_assistant_text_content( def format_thinking_content( - content: ThinkingContentModel, + content: ThinkingMessage, line_threshold: int = 20, preview_line_count: int = 5, ) -> str: """Format thinking content as HTML. Args: - content: ThinkingContentModel with the thinking text + content: ThinkingMessage with the thinking text line_threshold: Number of lines before content becomes collapsible preview_line_count: Number of preview lines to show when collapsed @@ -95,11 +95,11 @@ def format_image_content(image: ImageContent) -> str: return f'Uploaded image' -def format_unknown_content(content: UnknownContent) -> str: +def format_unknown_content(content: UnknownMessage) -> str: """Format unknown content type as HTML. Args: - content: UnknownContent with the type name + content: UnknownMessage with the type name Returns: HTML paragraph with escaped type name diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index c05bc85d..1f338112 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -6,25 +6,26 @@ from ..cache import get_library_version from ..models import ( - AssistantTextContent, - BashInputContent, - BashOutputContent, - CommandOutputContent, - CompactedSummaryContent, - DedupNoticeContent, - HookSummaryContent, - SessionHeaderContent, - SlashCommandContent, - SystemContent, - ThinkingContentModel, + AssistantTextMessage, + BashInputMessage, + BashOutputMessage, + CommandOutputMessage, + CompactedSummaryMessage, + DedupNoticeMessage, + HookSummaryMessage, + SessionHeaderMessage, + SlashCommandMessage, + SystemMessage, + ThinkingMessage, ToolResultContent, - ToolResultContentModel, + ToolResultMessage, ToolUseContent, + ToolUseMessage, TranscriptEntry, - UnknownContent, - UserMemoryContent, - UserSlashCommandContent, - UserTextContent, + UnknownMessage, + UserMemoryMessage, + UserSlashCommandMessage, + UserTextMessage, ) from ..renderer import ( Renderer, @@ -103,40 +104,54 @@ def _build_dispatcher(self) -> dict[type, Callable[..., str]]: """ return { # System content types - SystemContent: format_system_content, - HookSummaryContent: format_hook_summary_content, - SessionHeaderContent: format_session_header_content, - DedupNoticeContent: format_dedup_notice_content, + SystemMessage: format_system_content, + HookSummaryMessage: format_hook_summary_content, + SessionHeaderMessage: format_session_header_content, + DedupNoticeMessage: 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, + SlashCommandMessage: format_slash_command_content, + CommandOutputMessage: format_command_output_content, + BashInputMessage: format_bash_input_content, + BashOutputMessage: format_bash_output_content, + CompactedSummaryMessage: format_compacted_summary_content, + UserMemoryMessage: format_user_memory_content, + UserSlashCommandMessage: format_user_slash_command_content, + UserTextMessage: 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, + ThinkingMessage: partial(format_thinking_content, line_threshold=10), + AssistantTextMessage: format_assistant_text_content, + UnknownMessage: format_unknown_content, # Tool content types ToolUseContent: format_tool_use_content, - ToolResultContentModel: self._format_tool_result_content, + ToolUseMessage: self._format_tool_use_message, + ToolResultMessage: 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, + def _format_tool_result_content(self, content: ToolResultMessage) -> str: + """Format ToolResultMessage with associated tool context.""" + # output is ToolOutput (either specialized output or ToolResultContent) + if isinstance(content.output, ToolResultContent): + return format_tool_result_content( + content.output, + content.file_path, + content.tool_name, + ) + # TODO: Handle specialized output types (ReadOutput, EditOutput) + # For now, fallback to string representation + return f"
{content.output}
" + + def _format_tool_use_message(self, content: ToolUseMessage) -> str: + """Format ToolUseMessage with parsed input. + + ToolUseMessage wraps the parsed input for specialized formatting. + Falls back to generic formatting using ToolUseContent if needed. + """ + from .tool_formatters import format_tool_use_from_input + + return format_tool_use_from_input( + content.input, content.tool_name, + content.raw_input, ) def _flatten_preorder( diff --git a/claude_code_log/html/system_formatters.py b/claude_code_log/html/system_formatters.py index 72eff8b8..2d71876d 100644 --- a/claude_code_log/html/system_formatters.py +++ b/claude_code_log/html/system_formatters.py @@ -2,28 +2,28 @@ 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 +- system_formatters.py: SystemMessage, HookSummaryMessage +- user_formatters.py: SlashCommandMessage, CommandOutputMessage, etc. +- assistant_formatters.py: AssistantTextMessage, ThinkingMessage, ImageContent +- tool_formatters.py: tool use/result content """ import html from .ansi_colors import convert_ansi_to_html from ..models import ( - DedupNoticeContent, - HookSummaryContent, - SessionHeaderContent, - SystemContent, + DedupNoticeMessage, + HookSummaryMessage, + SessionHeaderMessage, + SystemMessage, ) -def format_system_content(content: SystemContent) -> str: +def format_system_content(content: SystemMessage) -> str: """Format a system message with level-specific icon. Args: - content: SystemContent with level and text + content: SystemMessage with level and text Returns: HTML with icon and ANSI-converted text @@ -33,13 +33,13 @@ def format_system_content(content: SystemContent) -> str: return f"{level_icon} {html_content}" -def format_hook_summary_content(content: HookSummaryContent) -> str: +def format_hook_summary_content(content: HookSummaryMessage) -> 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 + content: HookSummaryMessage with execution details Returns: HTML with collapsible details section @@ -79,11 +79,11 @@ def format_hook_summary_content(content: HookSummaryContent) -> str: """ -def format_session_header_content(content: SessionHeaderContent) -> str: +def format_session_header_content(content: SessionHeaderMessage) -> str: """Format a session header as HTML. Args: - content: SessionHeaderContent with title, session_id, and optional summary + content: SessionHeaderMessage with title, session_id, and optional summary Returns: HTML for the session header display @@ -92,11 +92,11 @@ def format_session_header_content(content: SessionHeaderContent) -> str: return escaped_title -def format_dedup_notice_content(content: DedupNoticeContent) -> str: +def format_dedup_notice_content(content: DedupNoticeMessage) -> str: """Format a deduplication notice as HTML. Args: - content: DedupNoticeContent with notice text and optional target link + content: DedupNoticeMessage with notice text and optional target link Returns: HTML for the dedup notice display with optional anchor link diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 962a9f2f..b55b751f 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -37,6 +37,7 @@ ReadOutput, TaskInput, TodoWriteInput, + ToolInput, ToolResultContent, ToolUseContent, WriteInput, @@ -738,6 +739,61 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: return render_params_table(tool_use.input) +def format_tool_use_from_input( + parsed_input: "ToolInput", + tool_name: str, + raw_input: Optional[dict[str, Any]] = None, +) -> str: + """Format tool use from pre-parsed input. + + This is the dispatcher for ToolUseMessage which already has parsed input. + Falls back to rendering the raw input dict if parsing was incomplete. + + Args: + parsed_input: The parsed ToolInput (specialized type or dict fallback) + tool_name: Name of the tool for context + raw_input: Original input dict for fallback rendering + + Returns: + HTML string for the tool use content + """ + # Dispatch based on parsed type + if isinstance(parsed_input, TodoWriteInput): + return format_todowrite_content(parsed_input) + + if isinstance(parsed_input, BashInput): + return format_bash_tool_content(parsed_input) + + if isinstance(parsed_input, EditInput): + return format_edit_tool_content(parsed_input) + + if isinstance(parsed_input, MultiEditInput): + return format_multiedit_tool_content(parsed_input) + + if isinstance(parsed_input, WriteInput): + return format_write_tool_content(parsed_input) + + if isinstance(parsed_input, TaskInput): + return format_task_tool_content(parsed_input) + + if isinstance(parsed_input, ReadInput): + return format_read_tool_content(parsed_input) + + if isinstance(parsed_input, AskUserQuestionInput): + return format_askuserquestion_content(parsed_input) + + if isinstance(parsed_input, ExitPlanModeInput): + return format_exitplanmode_content(parsed_input) + + # Default: render as key/value table + if isinstance(parsed_input, dict): + return render_params_table(parsed_input) + if raw_input is not None: + return render_params_table(raw_input) + # Last resort: string representation + return f"
{parsed_input}
" + + # -- Tool Result Content Formatter ------------------------------------------- @@ -966,6 +1022,7 @@ def format_tool_result_content( "render_params_table", # Dispatcher "format_tool_use_content", + "format_tool_use_from_input", # Tool result "format_tool_result_content", ] diff --git a/claude_code_log/html/user_formatters.py b/claude_code_log/html/user_formatters.py index 5c6235b4..098a467f 100644 --- a/claude_code_log/html/user_formatters.py +++ b/claude_code_log/html/user_formatters.py @@ -2,27 +2,27 @@ 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 +- system_formatters.py: SystemMessage, HookSummaryMessage +- user_formatters.py: SlashCommandMessage, CommandOutputMessage, etc. +- assistant_formatters.py: AssistantTextMessage, ThinkingMessage, ImageContent - tool_formatters.py: tool use/result content """ from .ansi_colors import convert_ansi_to_html from ..models import ( - BashInputContent, - BashOutputContent, - CommandOutputContent, - CompactedSummaryContent, + BashInputMessage, + BashOutputMessage, + CommandOutputMessage, + CompactedSummaryMessage, IdeDiagnostic, IdeNotificationContent, IdeOpenedFile, IdeSelection, ImageContent, - SlashCommandContent, - UserMemoryContent, - UserSlashCommandContent, - UserTextContent, + SlashCommandMessage, + UserMemoryMessage, + UserSlashCommandMessage, + UserTextMessage, ) from .tool_formatters import render_params_table from .utils import escape_html, render_collapsible_code, render_markdown_collapsible @@ -33,11 +33,11 @@ # ============================================================================= -def format_slash_command_content(content: SlashCommandContent) -> str: +def format_slash_command_content(content: SlashCommandMessage) -> str: """Format slash command content as HTML. Args: - content: SlashCommandContent with command name, args, and contents + content: SlashCommandMessage with command name, args, and contents Returns: HTML string for the slash command display @@ -75,11 +75,11 @@ def format_slash_command_content(content: SlashCommandContent) -> str: return "
".join(content_parts) -def format_command_output_content(content: CommandOutputContent) -> str: +def format_command_output_content(content: CommandOutputMessage) -> str: """Format command output content as HTML. Args: - content: CommandOutputContent with stdout and is_markdown flag + content: CommandOutputMessage with stdout and is_markdown flag Returns: HTML string for the command output display @@ -96,11 +96,11 @@ def format_command_output_content(content: CommandOutputContent) -> str: return f"
{html_content}
" -def format_bash_input_content(content: BashInputContent) -> str: +def format_bash_input_content(content: BashInputMessage) -> str: """Format bash input content as HTML. Args: - content: BashInputContent with the bash command + content: BashInputMessage with the bash command Returns: HTML string for the bash input display @@ -113,14 +113,14 @@ def format_bash_input_content(content: BashInputContent) -> str: def format_bash_output_content( - content: BashOutputContent, + content: BashOutputMessage, collapse_threshold: int = 10, preview_lines: int = 3, ) -> str: """Format bash output content as HTML. Args: - content: BashOutputContent with stdout and/or stderr + content: BashOutputMessage with stdout and/or stderr collapse_threshold: Number of lines before output becomes collapsible preview_lines: Number of preview lines to show when collapsed @@ -191,8 +191,8 @@ def format_user_text_content(text: str) -> str: return f"
{escaped_text}
" -def format_user_text_model_content(content: UserTextContent) -> str: - """Format UserTextContent model as HTML. +def format_user_text_model_content(content: UserTextMessage) -> str: + """Format UserTextMessage model as HTML. Handles user text with optional IDE notifications, compacted summaries, memory input markers, and inline images. @@ -202,10 +202,8 @@ def format_user_text_model_content(content: UserTextContent) -> str: - ImageContent: Rendered as inline 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/items and optional flags/notifications + content: UserTextMessage with text/items and optional flags/notifications Returns: HTML string combining all content items @@ -229,14 +227,14 @@ def format_user_text_model_content(content: UserTextContent) -> str: return "\n".join(parts) -def format_compacted_summary_content(content: CompactedSummaryContent) -> str: +def format_compacted_summary_content(content: CompactedSummaryMessage) -> 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 + content: CompactedSummaryMessage with summary text Returns: HTML string with collapsible markdown rendering @@ -249,14 +247,14 @@ def format_compacted_summary_content(content: CompactedSummaryContent) -> str: ) -def format_user_memory_content(content: UserMemoryContent) -> str: +def format_user_memory_content(content: UserMemoryMessage) -> 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 + content: UserMemoryMessage with memory text Returns: HTML string with escaped text in a pre tag @@ -265,14 +263,14 @@ def format_user_memory_content(content: UserMemoryContent) -> str: return f"
{escaped_text}
" -def format_user_slash_command_content(content: UserSlashCommandContent) -> str: +def format_user_slash_command_content(content: UserSlashCommandMessage) -> 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 + content: UserSlashCommandMessage with markdown text Returns: HTML string with collapsible markdown rendering diff --git a/claude_code_log/html/utils.py b/claude_code_log/html/utils.py index c62657a8..3bc6396f 100644 --- a/claude_code_log/html/utils.py +++ b/claude_code_log/html/utils.py @@ -23,24 +23,25 @@ from .renderer_code import highlight_code_with_pygments, truncate_highlighted_preview from ..models import ( - AssistantTextContent, - BashInputContent, - BashOutputContent, - CommandOutputContent, - CompactedSummaryContent, - HookSummaryContent, + AssistantTextMessage, + BashInputMessage, + BashOutputMessage, + CommandOutputMessage, + CompactedSummaryMessage, + HookSummaryMessage, MessageContent, - SessionHeaderContent, - SlashCommandContent, - SystemContent, - ThinkingContentModel, - ToolResultContentModel, + SessionHeaderMessage, + SlashCommandMessage, + SystemMessage, + ThinkingMessage, + ToolResultMessage, ToolUseContent, - UnknownContent, - UserMemoryContent, - UserSlashCommandContent, - UserSteeringContent, - UserTextContent, + ToolUseMessage, + UnknownMessage, + UserMemoryMessage, + UserSlashCommandMessage, + UserSteeringMessage, + UserTextMessage, ) from ..renderer_timings import timing_stat @@ -55,27 +56,28 @@ CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = { # System message types - SystemContent: ["system"], # level added dynamically - HookSummaryContent: ["system", "system-hook"], + SystemMessage: ["system"], # level added dynamically + HookSummaryMessage: ["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"], + UserTextMessage: ["user"], + UserSteeringMessage: ["user", "steering"], + SlashCommandMessage: ["user", "slash-command"], + UserSlashCommandMessage: ["user", "slash-command"], + UserMemoryMessage: ["user"], + CompactedSummaryMessage: ["user", "compacted"], + CommandOutputMessage: ["user", "command-output"], # Assistant message types - AssistantTextContent: ["assistant"], + AssistantTextMessage: ["assistant"], # Tool message types ToolUseContent: ["tool_use"], - ToolResultContentModel: ["tool_result"], # error added dynamically + ToolUseMessage: ["tool_use"], # Wrapper for specialized formatting + ToolResultMessage: ["tool_result"], # error added dynamically # Other message types - ThinkingContentModel: ["thinking"], - SessionHeaderContent: ["session_header"], - BashInputContent: ["bash-input"], - BashOutputContent: ["bash-output"], - UnknownContent: ["unknown"], + ThinkingMessage: ["thinking"], + SessionHeaderMessage: ["session_header"], + BashInputMessage: ["bash-input"], + BashOutputMessage: ["bash-output"], + UnknownMessage: ["unknown"], } @@ -91,9 +93,9 @@ def _get_css_classes_from_content(content: MessageContent) -> list[str]: if classes := CSS_CLASS_REGISTRY.get(cls): result = list(classes) # Dynamic modifiers based on content attributes - if isinstance(content, SystemContent): + if isinstance(content, SystemMessage): result.append(f"system-{content.level}") - elif isinstance(content, ToolResultContentModel) and content.is_error: + elif isinstance(content, ToolResultMessage) and content.is_error: result.append("error") return result return [] @@ -149,7 +151,7 @@ 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 isinstance(msg.content, CommandOutputContent): + if isinstance(msg.content, CommandOutputMessage): return "" return "🤷" elif msg_type == "bash-input": @@ -163,7 +165,7 @@ def get_message_emoji(msg: "TemplateMessage") -> str: elif msg_type == "tool_use": return "🛠️" elif msg_type == "tool_result": - if isinstance(msg.content, ToolResultContentModel) and msg.content.is_error: + if isinstance(msg.content, ToolResultMessage) and msg.content.is_error: return "🚨" return "🧰" elif msg_type == "thinking": diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 620d3133..02356879 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -67,7 +67,7 @@ class MessageContent: @dataclass -class SystemContent(MessageContent): +class SystemMessage(MessageContent): """System message with level indicator. Used for info, warning, and error system messages. @@ -86,7 +86,7 @@ class HookInfo: @dataclass -class HookSummaryContent(MessageContent): +class HookSummaryMessage(MessageContent): """Hook execution summary. Used for subtype="stop_hook_summary" system messages. @@ -105,7 +105,7 @@ class HookSummaryContent(MessageContent): @dataclass -class SlashCommandContent(MessageContent): +class SlashCommandMessage(MessageContent): """Content for slash command invocations (e.g., /context, /model). These are user messages containing command-name, command-args, and @@ -118,7 +118,7 @@ class SlashCommandContent(MessageContent): @dataclass -class CommandOutputContent(MessageContent): +class CommandOutputMessage(MessageContent): """Content for local command output (e.g., output from /context). These are user messages containing local-command-stdout tags. @@ -129,7 +129,7 @@ class CommandOutputContent(MessageContent): @dataclass -class BashInputContent(MessageContent): +class BashInputMessage(MessageContent): """Content for inline bash commands in user messages. These are user messages containing bash-input tags. @@ -139,7 +139,7 @@ class BashInputContent(MessageContent): @dataclass -class BashOutputContent(MessageContent): +class BashOutputMessage(MessageContent): """Content for bash command output. These are user messages containing bash-stdout and/or bash-stderr tags. @@ -150,22 +150,37 @@ class BashOutputContent(MessageContent): @dataclass -class ToolResultContentModel(MessageContent): - """Content model for tool results with rendering context. +class ToolResultMessage(MessageContent): + """Message for tool results with rendering context. - Wraps ToolResultContent with additional context needed for rendering, - such as the associated tool name and file path. + Wraps ToolResultContent or specialized output 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]]] + output: ( + "ToolOutput" # Specialized (ReadOutput, etc.) or generic (ToolResultContent) + ) 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): +class ToolUseMessage(MessageContent): + """Message for tool invocations. + + Wraps ToolUseContent with the parsed input for specialized formatting. + """ + + input: "ToolInput" # Specialized (BashInput, etc.) or raw dict + tool_use_id: str # From ToolUseContent.id + tool_name: str # From ToolUseContent.name + raw_input: Optional[dict[str, Any]] = None # Original input dict for fallback + + +@dataclass +class CompactedSummaryMessage(MessageContent): """Content for compacted session summaries. These are user messages that contain previous conversation context @@ -178,7 +193,7 @@ class CompactedSummaryContent(MessageContent): @dataclass -class UserMemoryContent(MessageContent): +class UserMemoryMessage(MessageContent): """Content for user memory input. These are user messages containing user-memory-input tags. @@ -190,7 +205,7 @@ class UserMemoryContent(MessageContent): @dataclass -class UserSlashCommandContent(MessageContent): +class UserSlashCommandMessage(MessageContent): """Content for slash command expanded prompts (isMeta=True). These are LLM-generated instruction text from slash commands. @@ -270,7 +285,7 @@ class ImageContent(BaseModel): """Image content from the Anthropic API. This represents an image within a content array, not a standalone message. - Images are always part of UserTextContent.items or AssistantTextContent.items. + Images are always part of UserTextMessage.items or AssistantTextMessage.items. """ type: Literal["image"] @@ -278,7 +293,7 @@ class ImageContent(BaseModel): @dataclass -class UserTextContent(MessageContent): +class UserTextMessage(MessageContent): """Content for user text with interleaved images and IDE notifications. The `items` field preserves the original order of content: @@ -296,11 +311,11 @@ class UserTextContent(MessageContent): @dataclass -class UserSteeringContent(UserTextContent): +class UserSteeringMessage(UserTextMessage): """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. + items from the queue. Inherits from UserTextMessage. """ pass @@ -314,7 +329,7 @@ class UserSteeringContent(UserTextContent): @dataclass -class AssistantTextContent(MessageContent): +class AssistantTextMessage(MessageContent): """Content for assistant text messages with interleaved images. These are the text portions of assistant messages that get @@ -334,8 +349,8 @@ class AssistantTextContent(MessageContent): @dataclass -class ThinkingContentModel(MessageContent): - """Content for assistant thinking/reasoning blocks. +class ThinkingMessage(MessageContent): + """Message for assistant thinking/reasoning blocks. These are the blocks that show the assistant's internal reasoning process. @@ -349,7 +364,7 @@ class ThinkingContentModel(MessageContent): @dataclass -class UnknownContent(MessageContent): +class UnknownMessage(MessageContent): """Content for unknown/unrecognized content types. Used as a fallback when encountering content types that don't have @@ -360,15 +375,15 @@ class UnknownContent(MessageContent): # ============================================================================= -# Tool Output Content Models +# Tool Output 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. +# Typed models for tool outputs (symmetric with Tool Input Models). +# These are data containers stored inside ToolResultMessage.output, +# NOT standalone message types (so they don't inherit from MessageContent). @dataclass -class ReadOutput(MessageContent): +class ReadOutput: """Parsed Read tool output. Represents the result of reading a file with optional line range. @@ -385,7 +400,7 @@ class ReadOutput(MessageContent): @dataclass -class WriteOutput(MessageContent): +class WriteOutput: """Parsed Write tool output. Symmetric with WriteInput for tool_use → tool_result pairing. @@ -407,7 +422,7 @@ class EditDiff: @dataclass -class EditOutput(MessageContent): +class EditOutput: """Parsed Edit tool output. Contains diff information for file edits. @@ -422,7 +437,7 @@ class EditOutput(MessageContent): @dataclass -class BashOutput(MessageContent): +class BashOutput: """Parsed Bash tool output. Symmetric with BashInput for tool_use → tool_result pairing. @@ -438,7 +453,7 @@ class BashOutput(MessageContent): @dataclass -class TaskOutput(MessageContent): +class TaskOutput: """Parsed Task (sub-agent) tool output. Symmetric with TaskInput for tool_use → tool_result pairing. @@ -452,7 +467,7 @@ class TaskOutput(MessageContent): @dataclass -class GlobOutput(MessageContent): +class GlobOutput: """Parsed Glob tool output. Symmetric with GlobInput for tool_use → tool_result pairing. @@ -466,7 +481,7 @@ class GlobOutput(MessageContent): @dataclass -class GrepOutput(MessageContent): +class GrepOutput: """Parsed Grep tool output. Symmetric with GrepInput for tool_use → tool_result pairing. @@ -480,6 +495,17 @@ class GrepOutput(MessageContent): truncated: bool +# Union of all specialized output types + ToolResultContent as generic fallback +# Note: Uses forward reference for ToolResultContent (defined later with ContentItem types) +ToolOutput = Union[ + ReadOutput, + EditOutput, + # Add more specialized output types as they're implemented: + # WriteOutput, BashOutput, TaskOutput, GlobOutput, GrepOutput + "ToolResultContent", # Generic fallback for unparsed results +] + + # ============================================================================= # Renderer Content Models # ============================================================================= @@ -488,7 +514,7 @@ class GrepOutput(MessageContent): @dataclass -class SessionHeaderContent(MessageContent): +class SessionHeaderMessage(MessageContent): """Content for session headers in transcript rendering. Represents the header displayed at the start of each session @@ -501,7 +527,7 @@ class SessionHeaderContent(MessageContent): @dataclass -class DedupNoticeContent(MessageContent): +class DedupNoticeMessage(MessageContent): """Content for deduplication notices. Displayed when content is deduplicated (e.g., sidechain assistant @@ -742,13 +768,15 @@ class ThinkingContent(BaseModel): ] -class UserMessage(BaseModel): +class UserMessageModel(BaseModel): role: Literal["user"] content: list[ContentItem] - usage: Optional["UsageInfo"] = None # For type compatibility with AssistantMessage + usage: Optional["UsageInfo"] = ( + None # For type compatibility with AssistantMessageModel + ) -class AssistantMessage(BaseModel): +class AssistantMessageModel(BaseModel): """Assistant message model.""" id: str @@ -786,14 +814,14 @@ class BaseTranscriptEntry(BaseModel): class UserTranscriptEntry(BaseTranscriptEntry): type: Literal["user"] - message: UserMessage + message: UserMessageModel toolUseResult: Optional[ToolUseResult] = None agentId: Optional[str] = None # From toolUseResult when present class AssistantTranscriptEntry(BaseTranscriptEntry): type: Literal["assistant"] - message: AssistantMessage + message: AssistantMessageModel requestId: Optional[str] = None diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index a0528849..62082868 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -17,14 +17,14 @@ ToolResultContent, ImageContent, # User message content models - SlashCommandContent, - CommandOutputContent, - BashInputContent, - BashOutputContent, - CompactedSummaryContent, - UserMemoryContent, - UserSlashCommandContent, - UserTextContent, + SlashCommandMessage, + CommandOutputMessage, + BashInputMessage, + BashOutputMessage, + CompactedSummaryMessage, + UserMemoryMessage, + UserSlashCommandMessage, + UserTextMessage, IdeNotificationContent, IdeOpenedFile, IdeSelection, @@ -93,14 +93,14 @@ def parse_timestamp(timestamp_str: str) -> Optional[datetime]: # ============================================================================= -def parse_slash_command(text: str) -> Optional[SlashCommandContent]: +def parse_slash_command(text: str) -> Optional[SlashCommandMessage]: """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 + SlashCommandMessage if tags found, None otherwise """ command_name_match = re.search(r"([^<]+)", text) if not command_name_match: @@ -130,21 +130,21 @@ def parse_slash_command(text: str) -> Optional[SlashCommandContent]: except json.JSONDecodeError: command_contents = contents_text - return SlashCommandContent( + return SlashCommandMessage( command_name=command_name, command_args=command_args, command_contents=command_contents, ) -def parse_command_output(text: str) -> Optional[CommandOutputContent]: +def parse_command_output(text: str) -> Optional[CommandOutputMessage]: """Parse command output tags from text. Args: text: Raw text that may contain local-command-stdout tags Returns: - CommandOutputContent if tags found, None otherwise + CommandOutputMessage if tags found, None otherwise """ stdout_match = re.search( r"(.*?)", @@ -158,33 +158,33 @@ def parse_command_output(text: str) -> Optional[CommandOutputContent]: # Check if content looks like markdown (starts with markdown headers) is_markdown = bool(re.match(r"^#+\s+", stdout_content, re.MULTILINE)) - return CommandOutputContent(stdout=stdout_content, is_markdown=is_markdown) + return CommandOutputMessage(stdout=stdout_content, is_markdown=is_markdown) -def parse_bash_input(text: str) -> Optional[BashInputContent]: +def parse_bash_input(text: str) -> Optional[BashInputMessage]: """Parse bash input tags from text. Args: text: Raw text that may contain bash-input tags Returns: - BashInputContent if tags found, None otherwise + BashInputMessage if tags found, None otherwise """ bash_match = re.search(r"(.*?)", text, re.DOTALL) if not bash_match: return None - return BashInputContent(command=bash_match.group(1).strip()) + return BashInputMessage(command=bash_match.group(1).strip()) -def parse_bash_output(text: str) -> Optional[BashOutputContent]: +def parse_bash_output(text: str) -> Optional[BashOutputMessage]: """Parse bash output tags from text. Args: text: Raw text that may contain bash-stdout/bash-stderr tags Returns: - BashOutputContent if tags found, None otherwise + BashOutputMessage if tags found, None otherwise """ stdout_match = re.search(r"(.*?)", text, re.DOTALL) stderr_match = re.search(r"(.*?)", text, re.DOTALL) @@ -201,7 +201,7 @@ def parse_bash_output(text: str) -> Optional[BashOutputContent]: if stderr == "": stderr = None - return BashOutputContent(stdout=stdout, stderr=stderr) + return BashOutputMessage(stdout=stdout, stderr=stderr) # Shared regex patterns for IDE notification tags @@ -286,20 +286,20 @@ def parse_ide_notifications(text: str) -> Optional[IdeNotificationContent]: def parse_compacted_summary( content_list: list[ContentItem], -) -> Optional[CompactedSummaryContent]: +) -> Optional[CompactedSummaryMessage]: """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. + items are combined into a single CompactedSummaryMessage. Args: content_list: List of ContentItem from user message Returns: - CompactedSummaryContent if first text is a compacted summary, None otherwise + CompactedSummaryMessage if first text is a compacted summary, None otherwise """ if not content_list or not hasattr(content_list[0], "text"): return None @@ -315,7 +315,7 @@ def parse_compacted_summary( [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) + return CompactedSummaryMessage(summary_text=all_text) # Pattern for user memory input tag @@ -324,7 +324,7 @@ def parse_compacted_summary( ) -def parse_user_memory(text: str) -> Optional[UserMemoryContent]: +def parse_user_memory(text: str) -> Optional[UserMemoryMessage]: """Parse user memory input tag from text. User memory input contains context that the user has provided from @@ -334,18 +334,18 @@ def parse_user_memory(text: str) -> Optional[UserMemoryContent]: text: Raw text that may contain user memory input tag Returns: - UserMemoryContent if tag found, None otherwise + UserMemoryMessage 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 UserMemoryMessage(memory_text=memory_content) return None # Type alias for content models returned by parse_user_message_content UserMessageContent = Union[ - CompactedSummaryContent, UserMemoryContent, UserSlashCommandContent, UserTextContent + CompactedSummaryMessage, UserMemoryMessage, UserSlashCommandMessage, UserTextMessage ] @@ -357,10 +357,10 @@ def parse_user_message_content( Returns a content model for HtmlRenderer to format. The caller can use isinstance() checks to determine the content type: - - UserSlashCommandContent: Slash command expanded prompts (isMeta=True) - - CompactedSummaryContent: Session continuation summaries - - UserMemoryContent: User memory input from CLAUDE.md - - UserTextContent: Normal user text with optional IDE notifications and images + - UserSlashCommandMessage: Slash command expanded prompts (isMeta=True) + - CompactedSummaryMessage: Session continuation summaries + - UserMemoryMessage: User memory input from CLAUDE.md + - UserTextMessage: Normal user text with optional IDE notifications and images This function processes content items preserving their original order: - TextContent items have IDE notifications extracted, producing @@ -382,7 +382,7 @@ def parse_user_message_content( all_text = "\n\n".join( getattr(item, "text", "") for item in content_list if hasattr(item, "text") ) - return UserSlashCommandContent(text=all_text) if all_text else None + return UserSlashCommandMessage(text=all_text) if all_text else None # Get first text item for special case detection first_text_item = next( @@ -427,8 +427,8 @@ def parse_user_message_content( # Anthropic ImageContent - convert to our model items.append(ImageContent.model_validate(item.model_dump())) # type: ignore[union-attr] - # Return UserTextContent with items list - return UserTextContent(items=items) + # Return UserTextMessage with items list + return UserTextMessage(items=items) # ============================================================================= diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 3e5209d9..144b7bfc 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -21,25 +21,25 @@ ContentItem, TextContent, ToolResultContent, - ToolResultContentModel, + ToolResultMessage, ToolUseContent, ThinkingContent, - ThinkingContentModel, + ThinkingMessage, # Structured content types - AssistantTextContent, - CommandOutputContent, - CompactedSummaryContent, - DedupNoticeContent, + AssistantTextMessage, + CommandOutputMessage, + CompactedSummaryMessage, + DedupNoticeMessage, HookInfo, - HookSummaryContent, - SessionHeaderContent, - SlashCommandContent, - SystemContent, - UnknownContent, - UserMemoryContent, - UserSlashCommandContent, - UserSteeringContent, - UserTextContent, + HookSummaryMessage, + SessionHeaderMessage, + SlashCommandMessage, + SystemMessage, + UnknownMessage, + UserMemoryMessage, + UserSlashCommandMessage, + UserSteeringMessage, + UserTextMessage, ) from .parser import ( as_assistant_entry, @@ -716,28 +716,28 @@ def _process_regular_message( # Handle user-specific preprocessing if message_type == MessageType.USER: # Note: sidechain user messages are skipped before reaching this function - # Parse user content (is_meta triggers UserSlashCommandContent creation) + # Parse user content (is_meta triggers UserSlashCommandMessage creation) content_model = parse_user_message_content(items, is_slash_command=is_meta) # Determine message_title from content type - if isinstance(content_model, UserSlashCommandContent): + if isinstance(content_model, UserSlashCommandMessage): message_title = "User (slash command)" - elif isinstance(content_model, CompactedSummaryContent): + elif isinstance(content_model, CompactedSummaryMessage): message_title = "User (compacted conversation)" - elif isinstance(content_model, UserMemoryContent): + elif isinstance(content_model, UserMemoryMessage): message_title = "Memory" elif message_type == MessageType.ASSISTANT: - # Create AssistantTextContent directly from items + # Create AssistantTextMessage directly from items # (empty text already filtered by chunk_message_content) if items: - content_model = AssistantTextContent( + content_model = AssistantTextMessage( items=items # type: ignore[arg-type] ) if is_sidechain: # Update message title for display (only non-user types reach here) - if not isinstance(content_model, CompactedSummaryContent): + if not isinstance(content_model, CompactedSummaryMessage): message_title = "Sub-assistant" return is_sidechain, content_model, message_type, message_title @@ -773,7 +773,7 @@ def _process_system_message( HookInfo(command=info.get("command", "unknown")) for info in (message.hookInfos or []) ] - content = HookSummaryContent( + content = HookSummaryMessage( has_output=bool(message.hasOutput), hook_errors=message.hookErrors or [], hook_infos=hook_infos, @@ -785,7 +785,7 @@ def _process_system_message( else: # Create structured system content level = getattr(message, "level", "info") - content = SystemContent(level=level, text=message.content) + content = SystemMessage(level=level, text=message.content) # Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy) parent_uuid = getattr(message, "parentUuid", None) @@ -800,7 +800,7 @@ def _process_system_message( ancestry=[], # Will be assigned by _build_message_hierarchy uuid=message.uuid, parent_uuid=parent_uuid, - content=content, # Level info is in SystemContent + content=content, # Level info is in SystemMessage ) @@ -962,9 +962,11 @@ def _process_tool_result_item( result_file_path = tool_use_from_ctx.input["file_path"] # Create content model with rendering context - content_model = ToolResultContentModel( + # Pass the whole ToolResultContent as output (generic fallback) + # TODO: Parse into specialized output types (ReadOutput, EditOutput) when appropriate + content_model = ToolResultMessage( tool_use_id=tool_result.tool_use_id, - content=tool_result.content, + output=tool_result, # ToolResultContent as ToolOutput is_error=tool_result.is_error or False, tool_name=result_tool_name, file_path=result_file_path, @@ -1017,7 +1019,7 @@ def _process_thinking_item(tool_item: ContentItem) -> Optional[ToolItemResult]: signature = None # Create the content model (formatting happens in HtmlRenderer) - thinking_model = ThinkingContentModel(thinking=thinking_text, signature=signature) + thinking_model = ThinkingMessage(thinking=thinking_text, signature=signature) return ToolItemResult( message_type="thinking", @@ -1071,7 +1073,7 @@ def _build_pairing_indices(messages: list[TemplateMessage]) -> PairingIndices: # Index slash-command user messages by parent_uuid if msg.parent_uuid and isinstance( - msg.content, (SlashCommandContent, UserSlashCommandContent) + msg.content, (SlashCommandMessage, UserSlashCommandMessage) ): slash_command_by_parent[msg.parent_uuid] = i @@ -1106,8 +1108,8 @@ def _try_pair_adjacent( """ # Slash command + command output (both are user messages) if isinstance( - current.content, (SlashCommandContent, UserSlashCommandContent) - ) and isinstance(next_msg.content, CommandOutputContent): + current.content, (SlashCommandMessage, UserSlashCommandMessage) + ) and isinstance(next_msg.content, CommandOutputMessage): _mark_pair(current, next_msg) return True @@ -1231,7 +1233,7 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe msg.is_paired and msg.pair_role == "pair_last" and msg.parent_uuid - and isinstance(msg.content, (SlashCommandContent, UserSlashCommandContent)) + and isinstance(msg.content, (SlashCommandMessage, UserSlashCommandMessage)) ): slash_command_pair_index[msg.parent_uuid] = i @@ -1333,8 +1335,8 @@ def _get_message_hierarchy_level(msg: TemplateMessage) -> int: return 1 # System info/warning at level 3 (tool-related, e.g., hook notifications) - # Get level from SystemContent if available - system_level = msg.content.level if isinstance(msg.content, SystemContent) else None + # Get level from SystemMessage if available + system_level = msg.content.level if isinstance(msg.content, SystemMessage) else None if ( msg_type == "system" and system_level in ("info", "warning") @@ -1665,7 +1667,7 @@ def _reorder_sidechain_template_messages( and sidechain_text == task_result_content ): # Replace with note pointing to the Task result - sidechain_msg.content = DedupNoticeContent( + sidechain_msg.content = DedupNoticeMessage( notice_text="Task summary — see result above", target_uuid=message.uuid, original_text=sidechain_text, @@ -1700,7 +1702,7 @@ def _resolve_dedup_targets(messages: list[TemplateMessage]) -> None: # Resolve dedup notice targets for msg in messages: - if isinstance(msg.content, DedupNoticeContent) and msg.content.target_uuid: + if isinstance(msg.content, DedupNoticeMessage) and msg.content.target_uuid: msg.content.target_message_id = uuid_to_id.get(msg.content.target_uuid) @@ -2017,7 +2019,7 @@ def _render_messages( is_session_header=True, message_id=None, ancestry=[], - content=SessionHeaderContent( + content=SessionHeaderMessage( title=session_title, session_id=session_id, summary=current_session_summary, @@ -2113,13 +2115,13 @@ def _render_messages( getattr(message, "isMeta", False), ) - # Convert to UserSteeringContent for queue-operation 'remove' messages + # Convert to UserSteeringMessage for queue-operation 'remove' messages if ( isinstance(message, QueueOperationTranscriptEntry) and message.operation == "remove" - and isinstance(content_model, UserTextContent) + and isinstance(content_model, UserTextMessage) ): - content_model = UserSteeringContent(items=content_model.items) + content_model = UserSteeringMessage(items=content_model.items) message_title = "User (steering)" # Skip empty chunks @@ -2139,9 +2141,9 @@ def _render_messages( has_markdown = isinstance( content_model, ( - AssistantTextContent, - ThinkingContentModel, - CompactedSummaryContent, + AssistantTextMessage, + ThinkingMessage, + CompactedSummaryMessage, ), ) @@ -2194,7 +2196,7 @@ def _render_messages( # Handle unknown content types tool_result = ToolItemResult( message_type="unknown", - content=UnknownContent(type_name=str(type(tool_item))), + content=UnknownMessage(type_name=str(type(tool_item))), message_title="Unknown Content", ) @@ -2214,9 +2216,7 @@ def _render_messages( ) # Thinking content uses markdown - tool_has_markdown = isinstance( - tool_result.content, ThinkingContentModel - ) + tool_has_markdown = isinstance(tool_result.content, ThinkingMessage) tool_template_message = TemplateMessage( message_type=tool_result.message_type, diff --git a/dev-docs/RENAME_CONTENT_TO_MESSAGE.md b/dev-docs/RENAME_CONTENT_TO_MESSAGE.md new file mode 100644 index 00000000..def039fb --- /dev/null +++ b/dev-docs/RENAME_CONTENT_TO_MESSAGE.md @@ -0,0 +1,173 @@ +# Refactoring Plan: Content → Message Naming + +## Goal + +Clarify the naming by using consistent suffixes: +- `*Content` = ContentItem members (JSONL parsing layer) +- `*Input` / `*Output` = Tool-specific parsing +- `*Message` = MessageContent subclasses (rendering layer) +- `*Model` = Pydantic JSONL transcript models + +## Phase 1: Free up "Message" names + +Rename Pydantic transcript message models to add `Model` suffix: + +| Current | New | +|---------|-----| +| `UserMessage` | `UserMessageModel` | +| `AssistantMessage` | `AssistantMessageModel` | + +These are only used in `UserTranscriptEntry.message` and `AssistantTranscriptEntry.message` for Pydantic deserialization. + +## Phase 2: Rename MessageContent subclasses to ...Message + +| Current | New | +|---------|-----| +| `UserTextContent` | `UserTextMessage` | +| `UserSteeringContent` | `UserSteeringMessage` | +| `UserSlashCommandContent` | `UserSlashCommandMessage` | +| `UserMemoryContent` | `UserMemoryMessage` | +| `AssistantTextContent` | `AssistantTextMessage` | +| `SlashCommandContent` | `SlashCommandMessage` | +| `CommandOutputContent` | `CommandOutputMessage` | +| `CompactedSummaryContent` | `CompactedSummaryMessage` | +| `BashInputContent` | `BashInputMessage` | +| `BashOutputContent` | `BashOutputMessage` | +| `SystemContent` | `SystemMessage` | +| `HookSummaryContent` | `HookSummaryMessage` | +| `SessionHeaderContent` | `SessionHeaderMessage` | +| `DedupNoticeContent` | `DedupNoticeMessage` | +| `UnknownContent` | `UnknownMessage` | +| `ThinkingContentModel` | `ThinkingMessage` | +| `ToolResultContentModel` | `ToolResultMessage` | + +Also update: +- `CSS_CLASS_REGISTRY` in `html/utils.py` +- All formatters in `html/*_formatters.py` +- All usages in `renderer.py`, `parser.py`, etc. + +## Phase 3: Tool message wrapper pattern with typed inputs/outputs + +### New type aliases + +```python +# Union of all specialized input types + ToolUseContent as generic fallback +ToolInput = Union[ + BashInput, ReadInput, WriteInput, EditInput, MultiEditInput, + GlobInput, GrepInput, TaskInput, TodoWriteInput, AskUserQuestionInput, + ExitPlanModeInput, NotebookEditInput, WebFetchInput, WebSearchInput, + KillShellInput, + ToolUseContent, # Generic fallback when no specialized parser +] + +# Renamed from ToolUseResult for symmetry +# Union of all specialized output types + ToolResultContent as generic fallback +ToolOutput = Union[ + ReadOutput, EditOutput, # ... more as they're implemented + ToolResultContent, # Generic fallback for unparsed results +] +``` + +### New ToolUseMessage + +```python +@dataclass +class ToolUseMessage(MessageContent): + """Message for tool invocations.""" + input: ToolInput # Specialized (BashInput, etc.) or generic (ToolUseContent) + tool_use_id: str # From ToolUseContent.id + tool_name: str # From ToolUseContent.name +``` + +### New ToolResultMessage + +```python +@dataclass +class ToolResultMessage(MessageContent): + """Message for tool results.""" + output: ToolOutput # Specialized (ReadOutput, etc.) or generic (ToolResultContent) + tool_use_id: str + tool_name: Optional[str] = None + file_path: Optional[str] = None + + @property + def is_error(self) -> bool: + if isinstance(self.output, ToolResultContent): + return self.output.is_error or False + return False +``` + +### Simple ThinkingMessage (no wrapper) + +```python +@dataclass +class ThinkingMessage(MessageContent): + thinking_text: str # The thinking content + signature: Optional[str] = None +``` + +## Phase 4: Update CSS_CLASS_REGISTRY + +Update to use new names: + +```python +CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = { + # System messages + SystemMessage: ["system"], + HookSummaryMessage: ["system", "system-hook"], + # User messages + UserTextMessage: ["user"], + UserSteeringMessage: ["user", "steering"], + SlashCommandMessage: ["user", "slash-command"], + UserSlashCommandMessage: ["user", "slash-command"], + UserMemoryMessage: ["user"], + CompactedSummaryMessage: ["user", "compacted"], + CommandOutputMessage: ["user", "command-output"], + # Assistant messages + AssistantTextMessage: ["assistant"], + # Tool messages + ToolUseMessage: ["tool_use"], + ToolResultMessage: ["tool_result"], + # Other messages + ThinkingMessage: ["thinking"], + SessionHeaderMessage: ["session_header"], + BashInputMessage: ["bash-input"], + BashOutputMessage: ["bash-output"], + UnknownMessage: ["unknown"], +} +``` + +## Naming Pattern Summary + +| Suffix | Layer | Examples | +|--------|-------|----------| +| `*Content` | ContentItem (JSONL parsing) | `TextContent`, `ToolUseContent`, `ToolResultContent`, `ThinkingContent`, `ImageContent` | +| `*Input` | Tool input parsing | `BashInput`, `ReadInput`, `TaskInput`, ... | +| `*Output` | Tool output parsing | `ReadOutput`, `EditOutput`, ... | +| `*Message` | MessageContent (rendering) | `UserTextMessage`, `ToolUseMessage`, `ThinkingMessage` | +| `*Model` | Pydantic JSONL models | `UserMessageModel`, `AssistantMessageModel` | + +## Files to Update + +| File | Changes | +|------|---------| +| `models.py` | All renames, new ToolInput/ToolOutput unions | +| `parser.py` | Update imports and usages | +| `renderer.py` | Update imports and usages | +| `html/utils.py` | Update CSS_CLASS_REGISTRY | +| `html/renderer.py` | Update dispatcher and imports | +| `html/user_formatters.py` | Update function signatures and imports | +| `html/assistant_formatters.py` | Update function signatures and imports | +| `html/tool_formatters.py` | Update to use ToolUseMessage/ToolResultMessage | +| `html/system_formatters.py` | Update function signatures and imports | +| `converter.py` | Update imports | +| `dev-docs/messages.md` | Update documentation | + +## Execution Order + +1. Phase 1: Rename `UserMessage` → `UserMessageModel`, `AssistantMessage` → `AssistantMessageModel` +2. Phase 2: Rename all MessageContent subclasses to `*Message` +3. Phase 3: Create `ToolInput`, `ToolOutput` unions; update `ToolUseMessage`, `ToolResultMessage` +4. Phase 4: Update CSS_CLASS_REGISTRY +5. Run tests, fix any remaining issues +6. Update documentation diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 09f9e9af..19d64179 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -21,35 +21,35 @@ JSONL Parsing (parser.py) │ ├── UserTranscriptEntry │ ├── TextContent → User message variants: -│ │ ├── UserSlashCommandContent (isMeta) or SlashCommandContent ( tags) -│ │ ├── CommandOutputContent ( tags) -│ │ ├── BashInputContent ( tags) -│ │ ├── CompactedSummaryContent (compacted conversation) -│ │ ├── UserSteeringContent (queue-operation "remove") +│ │ ├── UserSlashCommandMessage (isMeta) or SlashCommandMessage ( tags) +│ │ ├── CommandOutputMessage ( tags) +│ │ ├── BashInputMessage ( tags) +│ │ ├── CompactedSummaryMessage (compacted conversation) +│ │ ├── UserSteeringMessage (queue-operation "remove") │ │ └── Plain user text -│ ├── ToolResultContent → Tool result messages: +│ ├── ToolResultContent → ToolResultMessage with output: │ │ ├── ReadOutput (cat-n formatted file content) │ │ ├── EditOutput (cat-n formatted edit result) -│ │ └── Generic tool result text +│ │ └── ToolResultContent (generic fallback) │ └── ImageContent → Image messages │ ├── AssistantTranscriptEntry -│ ├── TextContent → AssistantTextContent -│ ├── ThinkingContent → ThinkingContentModel -│ └── ToolUseContent → Tool use messages with parsed inputs: +│ ├── TextContent → AssistantTextMessage +│ ├── ThinkingContent → ThinkingMessage +│ └── ToolUseContent → ToolUseMessage 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) +│ ├── SystemMessage (level: info/warning/error) +│ └── HookSummaryMessage (subtype: stop_hook_summary) │ ├── SummaryTranscriptEntry → Session metadata (not rendered) │ └── QueueOperationTranscriptEntry - └── "remove" operation → UserSteeringContent (rendered as user) + └── "remove" operation → UserSteeringMessage (rendered as user) ``` --- @@ -99,22 +99,22 @@ CSS classes are derived from the content type using `CSS_CLASS_REGISTRY` (in `ht | css_class | Content Type | Dynamic Modifier | |-----------|--------------|------------------| -| `"user"` | `UserTextContent` | — | -| `"user compacted"` | `CompactedSummaryContent` | — | -| `"user slash-command"` | `SlashCommandContent`, `UserSlashCommandContent` | — | -| `"user command-output"` | `CommandOutputContent` | — | -| `"user steering"` | `UserSteeringContent` | — | -| `"assistant"` | `AssistantTextContent` | — | -| `"tool_use"` | `ToolUseContent` | — | -| `"tool_result"` | `ToolResultContentModel` | — | -| `"tool_result error"` | `ToolResultContentModel` | `is_error=True` | -| `"thinking"` | `ThinkingContentModel` | — | -| `"bash-input"` | `BashInputContent` | — | -| `"bash-output"` | `BashOutputContent` | — | -| `"system system-info"` | `SystemContent` | `level="info"` | -| `"system system-warning"` | `SystemContent` | `level="warning"` | -| `"system system-error"` | `SystemContent` | `level="error"` | -| `"system system-hook"` | `HookSummaryContent` | — | +| `"user"` | `UserTextMessage` | — | +| `"user compacted"` | `CompactedSummaryMessage` | — | +| `"user slash-command"` | `SlashCommandMessage`, `UserSlashCommandMessage` | — | +| `"user command-output"` | `CommandOutputMessage` | — | +| `"user steering"` | `UserSteeringMessage` | — | +| `"assistant"` | `AssistantTextMessage` | — | +| `"tool_use"` | `ToolUseContent`, `ToolUseMessage` | — | +| `"tool_result"` | `ToolResultMessage` | — | +| `"tool_result error"` | `ToolResultMessage` | `is_error=True` | +| `"thinking"` | `ThinkingMessage` | — | +| `"bash-input"` | `BashInputMessage` | — | +| `"bash-output"` | `BashOutputMessage` | — | +| `"system system-info"` | `SystemMessage` | `level="info"` | +| `"system system-warning"` | `SystemMessage` | `level="warning"` | +| `"system system-error"` | `SystemMessage` | `level="error"` | +| `"system system-hook"` | `HookSummaryMessage` | — | The `sidechain` modifier is added when `msg.is_sidechain=True` (a cross-cutting concern that applies to any message type). @@ -158,7 +158,7 @@ Based on flags and tag patterns in `TextContent`, user text messages are classif ### Slash Command (isMeta) - **Condition**: `isMeta: true` flag -- **Content Model**: `UserSlashCommandContent` (models.py) +- **Content Model**: `UserSlashCommandMessage` (models.py) - **CSS Class**: `user slash-command` - **Files**: [user_slash_command.json](messages/user/user_slash_command.json) @@ -172,7 +172,7 @@ Based on flags and tag patterns in `TextContent`, user text messages are classif ```python @dataclass -class UserSlashCommandContent(MessageContent): +class UserSlashCommandMessage(MessageContent): text: str # LLM-generated markdown instruction text ``` @@ -182,13 +182,13 @@ class UserSlashCommandContent(MessageContent): ### Slash Command (Tags) - **Condition**: Contains `` tags -- **Content Model**: `SlashCommandContent` with parsed name/args/contents +- **Content Model**: `SlashCommandMessage` with parsed name/args/contents - **CSS Class**: `user slash-command` - **Files**: [user_command.json](messages/user/user_command.json) ```python @dataclass -class SlashCommandContent(MessageContent): +class SlashCommandMessage(MessageContent): command_name: str # e.g., "/model", "/context" command_args: str # Arguments after command command_contents: str # Content inside command @@ -202,13 +202,13 @@ class SlashCommandContent(MessageContent): ### Command Output - **Condition**: Contains `` tags -- **Content Model**: `CommandOutputContent` +- **Content Model**: `CommandOutputMessage` - **CSS Class**: `user command-output` - **Files**: [command_output.json](messages/user/command_output.json) ```python @dataclass -class CommandOutputContent(MessageContent): +class CommandOutputMessage(MessageContent): stdout: str # Command output text is_markdown: bool # True if content appears to be markdown ``` @@ -216,13 +216,13 @@ class CommandOutputContent(MessageContent): ### Bash Input - **Condition**: Contains `` tags -- **Content Model**: `BashInputContent` +- **Content Model**: `BashInputMessage` - **CSS Class**: `bash-input` (filtered by User) - **Files**: [bash_input.json](messages/user/bash_input.json) ```python @dataclass -class BashInputContent(MessageContent): +class BashInputMessage(MessageContent): command: str # The bash command that was executed ``` @@ -231,34 +231,34 @@ class BashInputContent(MessageContent): The corresponding output uses `` and optionally `` tags: - **Condition**: Contains `` tags -- **Content Model**: `BashOutputContent` +- **Content Model**: `BashOutputMessage` - **CSS Class**: `bash-output` (filtered by User) - **Files**: [bash_output.json](messages/user/bash_output.json) ### Compacted Conversation - **Condition**: Contains "(compacted conversation)" marker -- **Content Model**: `CompactedSummaryContent` +- **Content Model**: `CompactedSummaryMessage` - **CSS Class**: `user compacted` ```python @dataclass -class CompactedSummaryContent(MessageContent): +class CompactedSummaryMessage(MessageContent): summary_text: str # The compacted conversation summary ``` ### User Steering (Queue Remove) - **Condition**: `QueueOperationTranscriptEntry` with `operation: "remove"` -- **Content Model**: `UserSteeringContent` (extends `UserTextContent`) +- **Content Model**: `UserSteeringMessage` (extends `UserTextMessage`) - **CSS Class**: `user steering` - **Title**: "User (steering)" ```python @dataclass -class UserSteeringContent(UserTextContent): - """Content for user steering prompts (queue-operation 'remove').""" - pass # Inherits items from UserTextContent +class UserSteeringMessage(UserTextMessage): + """Message for user steering prompts (queue-operation 'remove').""" + pass # Inherits items from UserTextMessage ``` Steering messages represent user interrupts that cancel queued operations. @@ -381,16 +381,23 @@ class EditOutput(MessageContent): ### Tool Result Rendering Wrapper -Tool results are wrapped in `ToolResultContentModel` for rendering, which provides additional context: +Tool results are wrapped in `ToolResultMessage` for rendering, which provides additional context and typed output: ```python @dataclass -class ToolResultContentModel(MessageContent): +class ToolResultMessage(MessageContent): tool_use_id: str - content: Any # Union[str, List[Dict[str, Any]]] + output: ToolOutput # Specialized (ReadOutput, EditOutput) or ToolResultContent is_error: bool = False tool_name: Optional[str] = None # Name of the tool file_path: Optional[str] = None # File path for Read/Edit/Write + +# ToolOutput is a union type for tool results +ToolOutput = Union[ + ReadOutput, + EditOutput, + ToolResultContent, # Generic fallback for unparsed results +] ``` ## 1.4 Images (ImageContent) @@ -440,16 +447,16 @@ Assistant messages contain `ContentItem` instances that are: - **ThinkingContent**: Extended thinking blocks - **ToolUseContent**: Tool invocations -## 2.2 Assistant Text → AssistantTextContent +## 2.2 Assistant Text → AssistantTextMessage -- **Content Model**: `AssistantTextContent` (models.py) +- **Content Model**: `AssistantTextMessage` (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 +class AssistantTextMessage(MessageContent): + items: list[TextContent | ImageContent] # Interleaved text and images ``` ### Sidechain Assistant @@ -459,15 +466,15 @@ class AssistantTextContent(MessageContent): - **Title**: "Sub-assistant" - **Files**: [assistant_sidechain.json](messages/assistant/assistant_sidechain.json) -## 2.3 Thinking Content → ThinkingContentModel +## 2.3 Thinking Content → ThinkingMessage -- **Content Model**: `ThinkingContentModel` (models.py) +- **Content Model**: `ThinkingMessage` (models.py) - **CSS Class**: `thinking` - **Files**: [thinking.json](messages/assistant/thinking.json) ```python @dataclass -class ThinkingContentModel(MessageContent): +class ThinkingMessage(MessageContent): thinking: str # The thinking text signature: Optional[str] # Thinking block signature ``` @@ -481,14 +488,32 @@ class ThinkingContentModel(MessageContent): } ``` -## 2.4 Tool Use → ToolUseContent with Typed Inputs +## 2.4 Tool Use → ToolUseMessage with Typed Inputs + +Tool invocations are parsed from `ToolUseContent` (JSONL) and wrapped in `ToolUseMessage` for rendering: -Tool invocations contain a `ToolUseContent` item with: +```python +@dataclass +class ToolUseMessage(MessageContent): + input: ToolInput # Specialized (BashInput, etc.) or raw dict + tool_use_id: str # From ToolUseContent.id + tool_name: str # From ToolUseContent.name + raw_input: Optional[dict[str, Any]] = None # Fallback for generic rendering + +# ToolInput is a union of typed input models +ToolInput = Union[ + BashInput, ReadInput, WriteInput, EditInput, MultiEditInput, + GlobInput, GrepInput, TaskInput, TodoWriteInput, + AskUserQuestionInput, ExitPlanModeInput, + dict[str, Any], # Fallback for unknown tools +] +``` + +The original `ToolUseContent` (Pydantic model) provides: - `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()`. +- `parsed_input` property: Returns typed input model via `parse_tool_input()` ### Tool Input Models (models.py) @@ -564,18 +589,18 @@ System transcript entries (`type: "system"`) convey notifications and hook summa ## 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 +- **SystemMessage**: For info/warning/error messages +- **HookSummaryMessage**: For hook execution summaries -## 3.2 System Info/Warning/Error → SystemContent +## 3.2 System Info/Warning/Error → SystemMessage -- **Content Model**: `SystemContent` (models.py) +- **Content Model**: `SystemMessage` (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): +class SystemMessage(MessageContent): level: str # "info", "warning", "error" text: str # Raw text content (may contain ANSI codes) ``` @@ -588,9 +613,9 @@ class SystemContent(MessageContent): } ``` -## 3.3 Hook Summary → HookSummaryContent +## 3.3 Hook Summary → HookSummaryMessage -- **Content Model**: `HookSummaryContent` (models.py) +- **Content Model**: `HookSummaryMessage` (models.py) - **Condition**: `subtype: "stop_hook_summary"` - **CSS Class**: `system system-hook` @@ -600,7 +625,7 @@ class HookInfo: command: str @dataclass -class HookSummaryContent(MessageContent): +class HookSummaryMessage(MessageContent): has_output: bool hook_errors: List[str] hook_infos: List[HookInfo] @@ -646,26 +671,28 @@ The `leafUuid` links the summary to the last message of the session. These models are created during rendering to represent synthesized content not directly from JSONL entries. -## 5.1 SessionHeaderContent +## 5.1 SessionHeaderMessage Session headers are rendered at the start of each session: ```python @dataclass -class SessionHeaderContent(MessageContent): +class SessionHeaderMessage(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 +## 5.2 DedupNoticeMessage Deduplication notices are shown when content is deduplicated (e.g., sidechain assistant text that duplicates the Task tool result): ```python @dataclass -class DedupNoticeContent(MessageContent): +class DedupNoticeMessage(MessageContent): notice_text: str # e.g., "Content omitted (duplicates Task result)" + target_uuid: Optional[str] = None # UUID of target message + target_message_id: Optional[str] = None # Resolved message ID for anchor link ``` --- @@ -678,16 +705,33 @@ Display styling is derived from content types using `CSS_CLASS_REGISTRY` in `htm ```python CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = { + # System message types + SystemMessage: ["system"], # level added dynamically + HookSummaryMessage: ["system", "system-hook"], # User message types - UserTextContent: ["user"], - UserSteeringContent: ["user", "steering"], - SlashCommandContent: ["user", "slash-command"], - CompactedSummaryContent: ["user", "compacted"], - # ... more content types + UserTextMessage: ["user"], + UserSteeringMessage: ["user", "steering"], + SlashCommandMessage: ["user", "slash-command"], + UserSlashCommandMessage: ["user", "slash-command"], + UserMemoryMessage: ["user"], + CompactedSummaryMessage: ["user", "compacted"], + CommandOutputMessage: ["user", "command-output"], + # Assistant message types + AssistantTextMessage: ["assistant"], + # Tool message types + ToolUseContent: ["tool_use"], + ToolUseMessage: ["tool_use"], + ToolResultMessage: ["tool_result"], # error added dynamically + # Other message types + ThinkingMessage: ["thinking"], + SessionHeaderMessage: ["session_header"], + BashInputMessage: ["bash-input"], + BashOutputMessage: ["bash-output"], + UnknownMessage: ["unknown"], } ``` -The `_get_css_classes_from_content()` function walks the content type's MRO to find the matching registry entry, then adds dynamic modifiers (e.g., `system-{level}` for `SystemContent`). +The `_get_css_classes_from_content()` function walks the content type's MRO to find the matching registry entry, then adds dynamic modifiers (e.g., `system-{level}` for `SystemMessage`). The only cross-cutting modifier is `is_sidechain`, which is stored directly on `TemplateMessage` and appended to CSS classes when true. diff --git a/test/test_cache.py b/test/test_cache.py index e7b481b8..4cb4a23f 100644 --- a/test/test_cache.py +++ b/test/test_cache.py @@ -19,8 +19,8 @@ UserTranscriptEntry, AssistantTranscriptEntry, SummaryTranscriptEntry, - UserMessage, - AssistantMessage, + UserMessageModel, + AssistantMessageModel, UsageInfo, TextContent, ) @@ -60,7 +60,7 @@ def sample_entries(): uuid="user1", timestamp="2023-01-01T10:00:00Z", type="user", - message=UserMessage( + message=UserMessageModel( role="user", content=[TextContent(type="text", text="Hello")] ), ), @@ -74,7 +74,7 @@ def sample_entries(): uuid="assistant1", timestamp="2023-01-01T10:01:00Z", type="assistant", - message=AssistantMessage( + message=AssistantMessageModel( id="msg1", type="message", role="assistant", @@ -222,7 +222,7 @@ def test_filtered_loading_with_dates(self, cache_manager, temp_project_dir): uuid="user1", timestamp="2023-01-01T10:00:00Z", type="user", - message=UserMessage( + message=UserMessageModel( role="user", content=[TextContent(type="text", text="Early message")], ), @@ -237,7 +237,7 @@ def test_filtered_loading_with_dates(self, cache_manager, temp_project_dir): uuid="user2", timestamp="2023-01-02T10:00:00Z", type="user", - message=UserMessage( + message=UserMessageModel( role="user", content=[TextContent(type="text", text="Later message")], ), diff --git a/test/test_ide_tags.py b/test/test_ide_tags.py index 49554eef..2d451182 100644 --- a/test/test_ide_tags.py +++ b/test/test_ide_tags.py @@ -14,12 +14,12 @@ ) from claude_code_log.html.assistant_formatters import format_assistant_text_content from claude_code_log.models import ( - AssistantTextContent, + AssistantTextMessage, IdeNotificationContent, ImageContent, ImageSource, TextContent, - UserTextContent, + UserTextMessage, ) @@ -311,8 +311,8 @@ def test_parse_user_message_with_multi_item_content(self): content_model = parse_user_message_content(content_list) - # Should return UserTextContent with items - assert isinstance(content_model, UserTextContent) + # Should return UserTextMessage with items + assert isinstance(content_model, UserTextMessage) assert len(content_model.items) == 3 # IDE notification, text, image # First item should be IDE notification @@ -349,7 +349,7 @@ def test_format_user_text_content(self): def test_format_assistant_text_content(self): """Test that assistant text is formatted as markdown.""" - content = AssistantTextContent( + content = AssistantTextMessage( items=[TextContent(type="text", text="**Bold** response")] ) diff --git a/test/test_toggle_functionality.py b/test/test_toggle_functionality.py index 9ca59788..e91ee430 100644 --- a/test/test_toggle_functionality.py +++ b/test/test_toggle_functionality.py @@ -3,7 +3,7 @@ from typing import Any, Dict, List from claude_code_log.models import ( AssistantTranscriptEntry, - AssistantMessage, + AssistantMessageModel, UsageInfo, ) from claude_code_log.parser import parse_content_item @@ -20,8 +20,8 @@ def _create_assistant_message( # Convert raw content items to proper ContentItem objects parsed_content = [parse_content_item(item) for item in content_items] - # Create AssistantMessage with proper types - message = AssistantMessage( + # Create AssistantMessageModel with proper types + message = AssistantMessageModel( id="msg_001", type="message", role="assistant", diff --git a/test/test_user_renderer.py b/test/test_user_renderer.py index a301a685..f7dfd70d 100644 --- a/test/test_user_renderer.py +++ b/test/test_user_renderer.py @@ -19,10 +19,10 @@ format_user_text_model_content, ) from claude_code_log.models import ( - CompactedSummaryContent, + CompactedSummaryMessage, TextContent, - UserMemoryContent, - UserTextContent, + UserMemoryMessage, + UserTextMessage, ) from claude_code_log.parser import ( COMPACTED_SUMMARY_PREFIX, @@ -51,7 +51,7 @@ def test_parse_compacted_summary_detected(self): result = parse_compacted_summary(content_list) assert result is not None - assert isinstance(result, CompactedSummaryContent) + assert isinstance(result, CompactedSummaryMessage) assert result.summary_text == text def test_parse_compacted_summary_not_detected(self): @@ -101,7 +101,7 @@ def test_parse_user_memory_detected(self): result = parse_user_memory(text) assert result is not None - assert isinstance(result, UserMemoryContent) + assert isinstance(result, UserMemoryMessage) assert result.memory_text == "Memory content from CLAUDE.md" def test_parse_user_memory_with_surrounding_text(self): @@ -157,7 +157,7 @@ def test_compacted_summary_single_text_item(self): content_model = parse_user_message_content(content_list) assert content_model is not None - assert isinstance(content_model, CompactedSummaryContent) + assert isinstance(content_model, CompactedSummaryMessage) assert content_model.summary_text == text def test_compacted_summary_multiple_text_items(self): @@ -174,7 +174,7 @@ def test_compacted_summary_multiple_text_items(self): content_model = parse_user_message_content(content_list) assert content_model is not None - assert isinstance(content_model, CompactedSummaryContent) + assert isinstance(content_model, CompactedSummaryMessage) # All text items should be combined with double newlines expected = "\n\n".join([first_text, second_text, third_text]) assert content_model.summary_text == expected @@ -191,7 +191,7 @@ def test_user_memory_detected(self): content_model = parse_user_message_content(content_list) assert content_model is not None - assert isinstance(content_model, UserMemoryContent) + assert isinstance(content_model, UserMemoryMessage) assert content_model.memory_text == "CLAUDE.md content here" @@ -206,7 +206,7 @@ def test_regular_text(self): content_model = parse_user_message_content(content_list) assert content_model is not None - assert isinstance(content_model, UserTextContent) + assert isinstance(content_model, UserTextMessage) assert len(content_model.items) == 1 assert isinstance(content_model.items[0], TextContent) assert content_model.items[0].text == text @@ -225,12 +225,12 @@ def test_empty_content_list(self): # ============================================================================= -class TestFormatCompactedSummaryContent: +class TestFormatCompactedSummaryMessage: """Tests for format_compacted_summary_content() formatter function.""" def test_format_compacted_summary_basic(self): """Test basic compacted summary formatting.""" - content = CompactedSummaryContent(summary_text="Summary:\n- Point 1\n- Point 2") + content = CompactedSummaryMessage(summary_text="Summary:\n- Point 1\n- Point 2") html = format_compacted_summary_content(content) @@ -243,7 +243,7 @@ def test_format_compacted_summary_collapsible(self): """Test that long compacted summaries are collapsible.""" # Create long content that exceeds threshold long_summary = "Summary:\n" + "\n".join([f"- Point {i}" for i in range(50)]) - content = CompactedSummaryContent(summary_text=long_summary) + content = CompactedSummaryMessage(summary_text=long_summary) html = format_compacted_summary_content(content) @@ -257,12 +257,12 @@ def test_format_compacted_summary_collapsible(self): # ============================================================================= -class TestFormatUserMemoryContent: +class TestFormatUserMemoryMessage: """Tests for format_user_memory_content() formatter function.""" def test_format_user_memory_basic(self): """Test basic user memory formatting.""" - content = UserMemoryContent(memory_text="CLAUDE.md content") + content = UserMemoryMessage(memory_text="CLAUDE.md content") html = format_user_memory_content(content) @@ -272,7 +272,7 @@ def test_format_user_memory_basic(self): def test_format_user_memory_escapes_html(self): """Test that HTML characters are escaped.""" - content = UserMemoryContent(memory_text="") + content = UserMemoryMessage(memory_text="") html = format_user_memory_content(content) @@ -290,7 +290,7 @@ class TestFormatUserTextModelContent: def test_format_user_text_basic(self): """Test basic user text formatting.""" - content = UserTextContent( + content = UserTextMessage( items=[TextContent(type="text", text="User question here")] ) @@ -301,7 +301,7 @@ def test_format_user_text_basic(self): def test_format_user_text_escapes_html(self): """Test that HTML characters are escaped.""" - content = UserTextContent( + content = UserTextMessage( items=[TextContent(type="text", text='Test bold & "quotes"')] ) diff --git a/test/test_utils.py b/test/test_utils.py index 642390e8..2b190774 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -23,9 +23,9 @@ TextContent, ToolUseContent, UserTranscriptEntry, - UserMessage, + UserMessageModel, AssistantTranscriptEntry, - AssistantMessage, + AssistantMessageModel, ) @@ -621,7 +621,7 @@ def _create_user_entry( userType="external", cwd="/test", version="1.0.0", - message=UserMessage( + message=UserMessageModel( role="user", content=[TextContent(type="text", text=content)] ), uuid=uuid, @@ -645,7 +645,7 @@ def _create_assistant_entry( userType="external", cwd="/test", version="1.0.0", - message=AssistantMessage( + message=AssistantMessageModel( id="msg-id", type="message", role="assistant", @@ -754,7 +754,7 @@ def _create_user_entry( userType="external", cwd="/test", version="1.0.0", - message=UserMessage( + message=UserMessageModel( role="user", content=[TextContent(type="text", text=content)] ), uuid=uuid, diff --git a/test/test_version_deduplication.py b/test/test_version_deduplication.py index 0675fa85..6888683e 100644 --- a/test/test_version_deduplication.py +++ b/test/test_version_deduplication.py @@ -4,9 +4,9 @@ from datetime import datetime from claude_code_log.models import ( AssistantTranscriptEntry, - AssistantMessage, + AssistantMessageModel, UserTranscriptEntry, - UserMessage, + UserMessageModel, ToolUseContent, ToolResultContent, ) @@ -32,7 +32,7 @@ def test_assistant_message_deduplication(self): userType="external", cwd="/test", sessionId="session-test", - message=AssistantMessage( + message=AssistantMessageModel( id="msg_duplicate", type="message", role="assistant", @@ -63,7 +63,7 @@ def test_assistant_message_deduplication(self): userType="external", cwd="/test", sessionId="session-test", - message=AssistantMessage( + message=AssistantMessageModel( id="msg_duplicate", # SAME message.id type="message", role="assistant", @@ -112,7 +112,7 @@ def test_tool_result_deduplication(self): userType="external", cwd="/test", sessionId="session-test", - message=UserMessage( + message=UserMessageModel( role="user", content=[ ToolResultContent( @@ -134,7 +134,7 @@ def test_tool_result_deduplication(self): userType="external", cwd="/test", sessionId="session-test", - message=UserMessage( + message=UserMessageModel( role="user", content=[ ToolResultContent( @@ -170,7 +170,7 @@ def test_full_stutter_pair(self): userType="external", cwd="/test", sessionId="session-test", - message=AssistantMessage( + message=AssistantMessageModel( id="msg_full_test", type="message", role="assistant", @@ -197,7 +197,7 @@ def test_full_stutter_pair(self): userType="external", cwd="/test", sessionId="session-test", - message=UserMessage( + message=UserMessageModel( role="user", content=[ ToolResultContent( @@ -220,7 +220,7 @@ def test_full_stutter_pair(self): userType="external", cwd="/test", sessionId="session-test", - message=AssistantMessage( + message=AssistantMessageModel( id="msg_full_test", # SAME type="message", role="assistant", @@ -247,7 +247,7 @@ def test_full_stutter_pair(self): userType="external", cwd="/test", sessionId="session-test", - message=UserMessage( + message=UserMessageModel( role="user", content=[ ToolResultContent( @@ -292,7 +292,7 @@ def test_user_text_message_deduplication(self): userType="external", cwd="/test", sessionId="session-test", - message=UserMessage( + message=UserMessageModel( role="user", content=[ TextContent( @@ -318,7 +318,7 @@ def test_user_text_message_deduplication(self): userType="external", cwd="/test", sessionId="session-test", - message=UserMessage( + message=UserMessageModel( role="user", content=[ TextContent( @@ -340,7 +340,7 @@ def test_user_text_message_deduplication(self): userType="external", cwd="/test", sessionId="session-test", - message=UserMessage( + message=UserMessageModel( role="user", content=[ TextContent( From 6c88116e9f831115d83fa738990b88b7115fb75a Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 20 Dec 2025 20:26:04 +0100 Subject: [PATCH 02/57] Complete ToolUseMessage wrapper flow in renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update _process_tool_use_item to create ToolUseMessage wrapper with parsed input instead of returning raw ToolUseContent - Remove ToolUseContent from dispatcher (now only ToolUseMessage handled) - Remove ToolUseContent from CSS_CLASS_REGISTRY - Clean up unused imports - Fix stale references in messages.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 4 +--- claude_code_log/html/utils.py | 4 +--- claude_code_log/renderer.py | 11 ++++++++++- dev-docs/messages.md | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 1f338112..55947603 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -19,7 +19,6 @@ ThinkingMessage, ToolResultContent, ToolResultMessage, - ToolUseContent, ToolUseMessage, TranscriptEntry, UnknownMessage, @@ -56,7 +55,7 @@ format_thinking_content, format_unknown_content, ) -from .tool_formatters import format_tool_result_content, format_tool_use_content +from .tool_formatters import format_tool_result_content from .utils import css_class_from_message, get_message_emoji, get_template_environment if TYPE_CHECKING: @@ -122,7 +121,6 @@ def _build_dispatcher(self) -> dict[type, Callable[..., str]]: AssistantTextMessage: format_assistant_text_content, UnknownMessage: format_unknown_content, # Tool content types - ToolUseContent: format_tool_use_content, ToolUseMessage: self._format_tool_use_message, ToolResultMessage: self._format_tool_result_content, } diff --git a/claude_code_log/html/utils.py b/claude_code_log/html/utils.py index 3bc6396f..0cf90394 100644 --- a/claude_code_log/html/utils.py +++ b/claude_code_log/html/utils.py @@ -35,7 +35,6 @@ SystemMessage, ThinkingMessage, ToolResultMessage, - ToolUseContent, ToolUseMessage, UnknownMessage, UserMemoryMessage, @@ -69,8 +68,7 @@ # Assistant message types AssistantTextMessage: ["assistant"], # Tool message types - ToolUseContent: ["tool_use"], - ToolUseMessage: ["tool_use"], # Wrapper for specialized formatting + ToolUseMessage: ["tool_use"], ToolResultMessage: ["tool_result"], # error added dynamically # Other message types ThinkingMessage: ["thinking"], diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 144b7bfc..3da9f0d7 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -23,6 +23,7 @@ ToolResultContent, ToolResultMessage, ToolUseContent, + ToolUseMessage, ThinkingContent, ThinkingMessage, # Structured content types @@ -916,10 +917,18 @@ def _process_tool_use_item( # Populate tool_use_context for later use when processing tool results tool_use_context[item_tool_use_id] = tool_use + # Create ToolUseMessage wrapper with parsed input for specialized formatting + tool_use_message = ToolUseMessage( + input=tool_use.parsed_input, + tool_use_id=tool_use.id, + tool_name=tool_use.name, + raw_input=tool_use.input if isinstance(tool_use.input, dict) else None, + ) + return ToolItemResult( message_type="tool_use", message_title=tool_message_title, - content=tool_use, # ToolUseContent is the model + content=tool_use_message, tool_use_id=item_tool_use_id, title_hint=tool_title_hint, ) diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 19d64179..90b9ff96 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -864,9 +864,9 @@ Sub-agent messages (from `Task` tool): - [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 + - [system_formatters.py](../claude_code_log/html/system_formatters.py) - SystemMessage, HookSummaryMessage 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 + - [assistant_formatters.py](../claude_code_log/html/assistant_formatters.py) - AssistantTextMessage, ThinkingMessage, ImageContent 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 From 462de25253bf4bf169e8dc712eac51e5d3123e88 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 20 Dec 2025 20:29:00 +0100 Subject: [PATCH 03/57] Eliminate format_tool_use_content duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make format_tool_use_content a thin wrapper delegating to format_tool_use_from_input, removing duplicated dispatch logic - Update messages.md to remove ToolUseContent from CSS registry docs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/tool_formatters.py | 41 +++++-------------------- dev-docs/messages.md | 3 +- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index b55b751f..f1852048 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -702,41 +702,14 @@ def render_params_table(params: dict[str, Any]) -> str: 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. + Legacy wrapper that delegates to format_tool_use_from_input. + Kept for backward compatibility with tests that use ToolUseContent directly. """ - 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) + return format_tool_use_from_input( + tool_use.parsed_input, + tool_use.name, + tool_use.input if isinstance(tool_use.input, dict) else None, + ) def format_tool_use_from_input( diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 90b9ff96..32a49a54 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -105,7 +105,7 @@ CSS classes are derived from the content type using `CSS_CLASS_REGISTRY` (in `ht | `"user command-output"` | `CommandOutputMessage` | — | | `"user steering"` | `UserSteeringMessage` | — | | `"assistant"` | `AssistantTextMessage` | — | -| `"tool_use"` | `ToolUseContent`, `ToolUseMessage` | — | +| `"tool_use"` | `ToolUseMessage` | — | | `"tool_result"` | `ToolResultMessage` | — | | `"tool_result error"` | `ToolResultMessage` | `is_error=True` | | `"thinking"` | `ThinkingMessage` | — | @@ -719,7 +719,6 @@ CSS_CLASS_REGISTRY: dict[type[MessageContent], list[str]] = { # Assistant message types AssistantTextMessage: ["assistant"], # Tool message types - ToolUseContent: ["tool_use"], ToolUseMessage: ["tool_use"], ToolResultMessage: ["tool_result"], # error added dynamically # Other message types From d4c947268cfc53f9ef709cc8b27f8b4f62e6150b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 20 Dec 2025 21:59:45 +0100 Subject: [PATCH 04/57] Simplify ToolUseMessage to use ToolUseContent as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RENAME_CONTENT_TO_MESSAGE.md Phase 3 design: - Change ToolInput union fallback from dict[str, Any] to ToolUseContent - Remove raw_input field from ToolUseMessage (no longer needed) - Update _process_tool_use_item to pass ToolUseContent when no parser - Refactor format_tool_use_content to take ToolUseMessage directly - Remove _format_tool_use_message wrapper (now redundant) - Update tests to use ToolUseMessage with typed inputs - Update documentation to reflect the simplified design 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 18 ++-------- claude_code_log/html/tool_formatters.py | 43 +++++++----------------- claude_code_log/models.py | 6 ++-- claude_code_log/renderer.py | 5 +-- dev-docs/messages.md | 5 ++- test/test_todowrite_rendering.py | 44 ++++++++++++++----------- 6 files changed, 47 insertions(+), 74 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 55947603..aeb674a6 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -55,7 +55,7 @@ format_thinking_content, format_unknown_content, ) -from .tool_formatters import format_tool_result_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: @@ -121,7 +121,7 @@ def _build_dispatcher(self) -> dict[type, Callable[..., str]]: AssistantTextMessage: format_assistant_text_content, UnknownMessage: format_unknown_content, # Tool content types - ToolUseMessage: self._format_tool_use_message, + ToolUseMessage: format_tool_use_content, ToolResultMessage: self._format_tool_result_content, } @@ -138,20 +138,6 @@ def _format_tool_result_content(self, content: ToolResultMessage) -> str: # For now, fallback to string representation return f"
{content.output}
" - def _format_tool_use_message(self, content: ToolUseMessage) -> str: - """Format ToolUseMessage with parsed input. - - ToolUseMessage wraps the parsed input for specialized formatting. - Falls back to generic formatting using ToolUseContent if needed. - """ - from .tool_formatters import format_tool_use_from_input - - return format_tool_use_from_input( - content.input, - content.tool_name, - content.raw_input, - ) - def _flatten_preorder( self, roots: list[TemplateMessage] ) -> list[Tuple[TemplateMessage, str]]: diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index f1852048..7b96620c 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -37,9 +37,9 @@ ReadOutput, TaskInput, TodoWriteInput, - ToolInput, ToolResultContent, ToolUseContent, + ToolUseMessage, WriteInput, ) from .ansi_colors import convert_ansi_to_html @@ -699,37 +699,20 @@ def render_params_table(params: dict[str, Any]) -> str: # -- Tool Use Dispatcher ------------------------------------------------------ -def format_tool_use_content(tool_use: ToolUseContent) -> str: - """Format tool use content as HTML. +def format_tool_use_content(content: ToolUseMessage) -> str: + """Format ToolUseMessage as HTML. - Legacy wrapper that delegates to format_tool_use_from_input. - Kept for backward compatibility with tests that use ToolUseContent directly. - """ - return format_tool_use_from_input( - tool_use.parsed_input, - tool_use.name, - tool_use.input if isinstance(tool_use.input, dict) else None, - ) - - -def format_tool_use_from_input( - parsed_input: "ToolInput", - tool_name: str, - raw_input: Optional[dict[str, Any]] = None, -) -> str: - """Format tool use from pre-parsed input. - - This is the dispatcher for ToolUseMessage which already has parsed input. + Dispatches to specialized formatters based on the parsed input type. Falls back to rendering the raw input dict if parsing was incomplete. Args: - parsed_input: The parsed ToolInput (specialized type or dict fallback) - tool_name: Name of the tool for context - raw_input: Original input dict for fallback rendering + content: ToolUseMessage with parsed input and metadata Returns: HTML string for the tool use content """ + parsed_input = content.input + # Dispatch based on parsed type if isinstance(parsed_input, TodoWriteInput): return format_todowrite_content(parsed_input) @@ -758,12 +741,11 @@ def format_tool_use_from_input( if isinstance(parsed_input, ExitPlanModeInput): return format_exitplanmode_content(parsed_input) - # Default: render as key/value table - if isinstance(parsed_input, dict): - return render_params_table(parsed_input) - if raw_input is not None: - return render_params_table(raw_input) - # Last resort: string representation + # Fallback: ToolUseContent - render its input dict as params table + if isinstance(parsed_input, ToolUseContent): + return render_params_table(parsed_input.input) + + # Last resort: string representation (shouldn't happen with ToolInput union) return f"
{parsed_input}
" @@ -995,7 +977,6 @@ def format_tool_result_content( "render_params_table", # Dispatcher "format_tool_use_content", - "format_tool_use_from_input", # Tool result "format_tool_result_content", ] diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 02356879..d1c1c389 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -171,12 +171,12 @@ class ToolUseMessage(MessageContent): """Message for tool invocations. Wraps ToolUseContent with the parsed input for specialized formatting. + Falls back to the original ToolUseContent when no specialized parser exists. """ - input: "ToolInput" # Specialized (BashInput, etc.) or raw dict + input: "ToolInput" # Specialized (BashInput, etc.) or ToolUseContent fallback tool_use_id: str # From ToolUseContent.id tool_name: str # From ToolUseContent.name - raw_input: Optional[dict[str, Any]] = None # Original input dict for fallback @dataclass @@ -703,7 +703,7 @@ class ExitPlanModeInput(BaseModel): TodoWriteInput, AskUserQuestionInput, ExitPlanModeInput, - dict[str, Any], # Fallback for unknown tools + "ToolUseContent", # Generic fallback when no specialized parser ] diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 3da9f0d7..681030b3 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -918,11 +918,12 @@ def _process_tool_use_item( tool_use_context[item_tool_use_id] = tool_use # Create ToolUseMessage wrapper with parsed input for specialized formatting + # Use ToolUseContent as fallback when no specialized parser (parsed_input returns dict) + parsed = tool_use.parsed_input tool_use_message = ToolUseMessage( - input=tool_use.parsed_input, + input=parsed if not isinstance(parsed, dict) else tool_use, tool_use_id=tool_use.id, tool_name=tool_use.name, - raw_input=tool_use.input if isinstance(tool_use.input, dict) else None, ) return ToolItemResult( diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 32a49a54..9fea42c3 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -495,17 +495,16 @@ Tool invocations are parsed from `ToolUseContent` (JSONL) and wrapped in `ToolUs ```python @dataclass class ToolUseMessage(MessageContent): - input: ToolInput # Specialized (BashInput, etc.) or raw dict + input: ToolInput # Specialized (BashInput, etc.) or ToolUseContent fallback tool_use_id: str # From ToolUseContent.id tool_name: str # From ToolUseContent.name - raw_input: Optional[dict[str, Any]] = None # Fallback for generic rendering # ToolInput is a union of typed input models ToolInput = Union[ BashInput, ReadInput, WriteInput, EditInput, MultiEditInput, GlobInput, GrepInput, TaskInput, TodoWriteInput, AskUserQuestionInput, ExitPlanModeInput, - dict[str, Any], # Fallback for unknown tools + ToolUseContent, # Generic fallback when no specialized parser ] ``` diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index 3973269e..a4842818 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -7,7 +7,12 @@ import pytest from claude_code_log.converter import convert_jsonl_to_html from claude_code_log.html import format_todowrite_content, format_tool_use_content -from claude_code_log.models import TodoWriteInput, TodoWriteItem, ToolUseContent +from claude_code_log.models import ( + EditInput, + TodoWriteInput, + TodoWriteItem, + ToolUseMessage, +) class TestTodoWriteRendering: @@ -194,28 +199,29 @@ def test_todowrite_vs_regular_tool_use(self): "This is a very long content that should definitely exceed 200 characters so that we can test the collapsible details functionality properly. " * 3 ) - regular_tool = ToolUseContent( - type="tool_use", - id="toolu_regular", - name="Edit", - input={"file_path": "/tmp/test.py", "content": long_content}, + regular_tool = ToolUseMessage( + input=EditInput( + file_path="/tmp/test.py", + old_string="", + new_string=long_content, + ), + tool_use_id="toolu_regular", + tool_name="Edit", ) # Create TodoWrite tool use - todowrite_tool = ToolUseContent( - type="tool_use", - id="toolu_todowrite", - name="TodoWrite", - input={ - "todos": [ - { - "id": "1", - "content": "Test todo", - "status": "pending", - "priority": "medium", - } + todowrite_tool = ToolUseMessage( + input=TodoWriteInput( + todos=[ + TodoWriteItem( + content="Test todo", + status="pending", + activeForm="Testing todo", + ) ] - }, + ), + tool_use_id="toolu_todowrite", + tool_name="TodoWrite", ) # Test both through the main format function From 2ca3d31d53c4025467f73813c08d40128d9f82cb Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 20 Dec 2025 23:51:51 +0100 Subject: [PATCH 05/57] Add MessageMeta dataclass and system message parsing module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MessageMeta dataclass for common transcript entry fields - Make MessageContent a @dataclass with optional keyword-only meta field - Create system_parser.py with parse_system_transcript() function - Add parse_meta() function in parser.py to extract metadata - Refactor _process_system_message to use new parsing pattern: - parse_system_transcript() calls parse_meta() internally - Returns message with meta attached, uses message.message_title() - Fix ToolUseContent: remove incorrect MessageContent inheritance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 49 +++++++++++++++++++++-- claude_code_log/parser.py | 30 ++++++++++++++ claude_code_log/renderer.py | 68 ++++++++++++-------------------- claude_code_log/system_parser.py | 63 +++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 claude_code_log/system_parser.py diff --git a/claude_code_log/models.py b/claude_code_log/models.py index d1c1c389..774a56f8 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -45,6 +45,27 @@ class MessageType(str, Enum): SYSTEM_ERROR = "system-error" +# ============================================================================= +# Message Metadata +# ============================================================================= +# Common metadata fields extracted from transcript entries. + + +@dataclass +class MessageMeta: + """Common metadata extracted from transcript entries. + + These fields are shared across all message types and are used to create + the TemplateMessage wrapper for rendering. + """ + + session_id: str + timestamp: str # Raw ISO timestamp + formatted_timestamp: str # Human-readable formatted timestamp + uuid: str + parent_uuid: Optional[str] = None + + # ============================================================================= # Message Content Models # ============================================================================= @@ -53,17 +74,29 @@ class MessageType(str, Enum): # renderers (HTML, text, etc.) to format the content appropriately. +@dataclass 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. + The `meta` field is keyword-only with a default of None, allowing: + - Subclasses to have positional fields before it + - Progressive migration: call sites can pass meta=... when available + - Backward compatibility: parsing functions that don't have transcript access + can omit meta (the renderer can set it later if needed) """ - pass + meta: Optional[MessageMeta] = field(default=None, kw_only=True) + + def message_title(self) -> Optional[str]: + """Return a title for this message content, or None for default behavior. + + Subclasses can override this to provide a specific title that will be + used in the TemplateMessage wrapper. + """ + return None @dataclass @@ -76,6 +109,10 @@ class SystemMessage(MessageContent): level: str # "info", "warning", "error" text: str # Raw text content (may contain ANSI codes) + def message_title(self) -> Optional[str]: + """Return 'System Info', 'System Warning', or 'System Error'.""" + return f"System {self.level.title()}" + @dataclass class HookInfo: @@ -96,6 +133,10 @@ class HookSummaryMessage(MessageContent): hook_errors: list[str] # Error messages from hooks hook_infos: list[HookInfo] # Info about each hook executed + def message_title(self) -> Optional[str]: + """Return 'System Hook' for hook summary messages.""" + return "System Hook" + # ============================================================================= # User Message Content Models @@ -718,7 +759,7 @@ class UsageInfo(BaseModel): server_tool_use: Optional[dict[str, Any]] = None -class ToolUseContent(BaseModel, MessageContent): +class ToolUseContent(BaseModel): type: Literal["tool_use"] id: str name: str diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 62082868..82682b57 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -9,6 +9,9 @@ from pydantic import BaseModel from .models import ( + # Common metadata + MessageMeta, + BaseTranscriptEntry, # Content types ContentItem, TextContent, @@ -58,6 +61,33 @@ ) +def parse_meta(transcript: BaseTranscriptEntry) -> MessageMeta: + """Extract common metadata from a transcript entry. + + This function extracts the shared fields that are present in all + BaseTranscriptEntry subclasses and formats them for rendering. + + Args: + transcript: Any transcript entry inheriting from BaseTranscriptEntry + + Returns: + MessageMeta with session_id, timestamps, uuid, and parent_uuid + """ + # Local import to avoid circular dependency (utils.py imports from parser.py) + from .utils import format_timestamp + + timestamp = transcript.timestamp + formatted_timestamp = format_timestamp(timestamp) if timestamp else "" + + return MessageMeta( + session_id=transcript.sessionId, + timestamp=timestamp, + formatted_timestamp=formatted_timestamp, + uuid=transcript.uuid, + parent_uuid=transcript.parentUuid, + ) + + def extract_text_content(content: Optional[list[ContentItem]]) -> str: """Extract text content from Claude message content structure. diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 681030b3..a2ac6122 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -31,8 +31,6 @@ CommandOutputMessage, CompactedSummaryMessage, DedupNoticeMessage, - HookInfo, - HookSummaryMessage, SessionHeaderMessage, SlashCommandMessage, SystemMessage, @@ -745,63 +743,49 @@ def _process_regular_message( def _process_system_message( - message: SystemTranscriptEntry, + transcript: SystemTranscriptEntry, ) -> Optional[TemplateMessage]: - """Process a system message and return a TemplateMessage, or None if it should be skipped. + """Process a system transcript entry into a TemplateMessage. Handles: - Hook summaries (subtype="stop_hook_summary") - Other system messages with level-specific styling (info, warning, error) + Args: + transcript: The system transcript entry to process + + Returns: + TemplateMessage, or None if the message should be skipped + 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 = HookSummaryMessage( - 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) + from .system_parser import parse_system_transcript + + # Parse the transcript entry into structured message (with meta attached) + message = parse_system_transcript(transcript) + if message is None: return None - else: - # Create structured system content - level = getattr(message, "level", "info") - content = SystemMessage(level=level, text=message.content) - # Store parent UUID for hierarchy rebuild (handled by _build_message_hierarchy) - parent_uuid = getattr(message, "parentUuid", None) + # Get metadata from the message content + meta = message.meta + assert meta is not None, "parse_system_transcript should always set meta" + + # Get title from message (uses message_title() method) + title = message.message_title() or "System" return TemplateMessage( message_type="system", - formatted_timestamp=formatted_timestamp, - raw_timestamp=timestamp, - session_id=session_id, - message_title=f"System {level.title()}", + formatted_timestamp=meta.formatted_timestamp, + raw_timestamp=meta.timestamp, + session_id=meta.session_id, + message_title=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, - content=content, # Level info is in SystemMessage + uuid=meta.uuid, + parent_uuid=meta.parent_uuid, + content=message, ) diff --git a/claude_code_log/system_parser.py b/claude_code_log/system_parser.py new file mode 100644 index 00000000..f1881838 --- /dev/null +++ b/claude_code_log/system_parser.py @@ -0,0 +1,63 @@ +"""Parser for system transcript entries. + +This module handles parsing of SystemTranscriptEntry into MessageContent subclasses: +- SystemMessage: Regular system messages with level (info, warning, error) +- HookSummaryMessage: Hook execution summaries +""" + +from typing import Optional, Union + +from .models import ( + HookInfo, + HookSummaryMessage, + SystemMessage, + SystemTranscriptEntry, +) +from .parser import parse_meta + + +def parse_system_transcript( + transcript: SystemTranscriptEntry, +) -> Optional[Union[SystemMessage, HookSummaryMessage]]: + """Parse a system transcript entry into a MessageContent. + + Handles: + - Hook summaries (subtype="stop_hook_summary") + - Regular system messages with level-specific styling (info, warning, error) + + Args: + transcript: The system transcript entry to parse + + Returns: + SystemMessage or HookSummaryMessage (with meta attached), + or None if the message should be skipped (e.g., silent hook successes) + + Note: + Slash command messages (, ) are user messages, + not system messages. They are handled separately. + """ + if transcript.subtype == "stop_hook_summary": + # Skip silent hook successes (no output, no errors) + if not transcript.hasOutput and not transcript.hookErrors: + return None + # Create structured hook summary content + meta = parse_meta(transcript) + hook_infos = [ + HookInfo(command=info.get("command", "unknown")) + for info in (transcript.hookInfos or []) + ] + return HookSummaryMessage( + has_output=bool(transcript.hasOutput), + hook_errors=transcript.hookErrors or [], + hook_infos=hook_infos, + meta=meta, + ) + + if not transcript.content: + # Skip system messages without content (shouldn't happen normally) + return None + + # Create structured system content + meta = parse_meta(transcript) + level = getattr(transcript, "level", "info") + return SystemMessage(level=level, text=transcript.content, meta=meta) From 059bfaee0ea0b127bdf469faaab28f366940ab02 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 00:06:15 +0100 Subject: [PATCH 06/57] Move formatted_timestamp computation to render time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove formatted_timestamp from MessageMeta and TemplateMessage, computing it at the last moment in HtmlRenderer._flatten_preorder. This simplifies the data model and keeps formatting concerns in the rendering layer. Changes: - Remove formatted_timestamp field from MessageMeta dataclass - Remove formatted_timestamp from parse_meta() function - Remove formatted_timestamp parameter from TemplateMessage constructor - Update _flatten_preorder to return (msg, html, formatted_timestamp) tuple - Update transcript.html template to use the tuple's third element - Update test_template_data.py to not use formatted_timestamp 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 12 ++++++----- .../html/templates/transcript.html | 4 ++-- claude_code_log/models.py | 3 ++- claude_code_log/parser.py | 15 +++++-------- claude_code_log/renderer.py | 10 --------- test/test_template_data.py | 21 +++++++------------ 6 files changed, 23 insertions(+), 42 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index aeb674a6..50d4fda4 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -34,6 +34,7 @@ title_for_projects_index, ) from ..renderer_timings import log_timing +from ..utils import format_timestamp from .system_formatters import ( format_dedup_notice_content, format_hook_summary_content, @@ -140,23 +141,24 @@ def _format_tool_result_content(self, content: ToolResultMessage) -> str: def _flatten_preorder( self, roots: list[TemplateMessage] - ) -> list[Tuple[TemplateMessage, str]]: + ) -> list[Tuple[TemplateMessage, str, 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. + content to HTML, and builds a flat list of (message, html, timestamp) tuples. Args: roots: Root messages (typically session headers) with children populated Returns: - Flat list of (message, html_content) tuples in pre-order + Flat list of (message, html_content, formatted_timestamp) tuples in pre-order """ - flat: list[Tuple[TemplateMessage, str]] = [] + flat: list[Tuple[TemplateMessage, str, str]] = [] def visit(msg: TemplateMessage) -> None: html = self.format_content(msg) - flat.append((msg, html)) + formatted_ts = format_timestamp(msg.raw_timestamp) + flat.append((msg, html, formatted_ts)) for child in msg.children: visit(child) diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 3957cfe7..2efac95c 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -69,7 +69,7 @@

🔍 Search & Filter

{{ render_session_nav(sessions, "toc") }} {% endif %} - {% for message, html_content in messages %} + {% for message, html_content, formatted_timestamp in messages %} {% if message.is_session_header %}
@@ -111,7 +111,7 @@

🔍 Search & Filter

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 }} + {{ formatted_timestamp }}
{% if message.token_usage %} {{ message.token_usage }} diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 774a56f8..828fa878 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -57,11 +57,12 @@ class MessageMeta: These fields are shared across all message types and are used to create the TemplateMessage wrapper for rendering. + + Note: formatted_timestamp is computed at render time, not stored here. """ session_id: str timestamp: str # Raw ISO timestamp - formatted_timestamp: str # Human-readable formatted timestamp uuid: str parent_uuid: Optional[str] = None diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 82682b57..12bb9530 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -65,24 +65,19 @@ def parse_meta(transcript: BaseTranscriptEntry) -> MessageMeta: """Extract common metadata from a transcript entry. This function extracts the shared fields that are present in all - BaseTranscriptEntry subclasses and formats them for rendering. + BaseTranscriptEntry subclasses. + + Note: formatted_timestamp is computed at render time, not here. Args: transcript: Any transcript entry inheriting from BaseTranscriptEntry Returns: - MessageMeta with session_id, timestamps, uuid, and parent_uuid + MessageMeta with session_id, timestamp, uuid, and parent_uuid """ - # Local import to avoid circular dependency (utils.py imports from parser.py) - from .utils import format_timestamp - - timestamp = transcript.timestamp - formatted_timestamp = format_timestamp(timestamp) if timestamp else "" - return MessageMeta( session_id=transcript.sessionId, - timestamp=timestamp, - formatted_timestamp=formatted_timestamp, + timestamp=transcript.timestamp, uuid=transcript.uuid, parent_uuid=transcript.parentUuid, ) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index a2ac6122..cd8cde8c 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -171,7 +171,6 @@ class TemplateMessage: def __init__( self, message_type: str, - formatted_timestamp: str, raw_timestamp: Optional[str] = None, session_summary: Optional[str] = None, session_id: Optional[str] = None, @@ -193,7 +192,6 @@ def __init__( self.type = message_type # Structured content for rendering self.content = content - self.formatted_timestamp = formatted_timestamp self.is_sidechain = is_sidechain self.raw_timestamp = raw_timestamp # Display title for message header (capitalized, with decorations) @@ -777,7 +775,6 @@ def _process_system_message( return TemplateMessage( message_type="system", - formatted_timestamp=meta.formatted_timestamp, raw_timestamp=meta.timestamp, session_id=meta.session_id, message_title=title, @@ -2006,7 +2003,6 @@ def _render_messages( session_header = TemplateMessage( message_type="session_header", - formatted_timestamp="", raw_timestamp=None, session_summary=current_session_summary, session_id=session_id, @@ -2023,7 +2019,6 @@ def _render_messages( # Get timestamp (only for non-summary messages) 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 @@ -2143,7 +2138,6 @@ def _render_messages( template_message = TemplateMessage( message_type=chunk_message_type, - formatted_timestamp=formatted_timestamp, raw_timestamp=timestamp, session_summary=session_summary, session_id=session_id, @@ -2168,9 +2162,6 @@ def _render_messages( # Special chunk: single tool_use/tool_result/thinking item tool_item = chunk tool_timestamp = getattr(message, "timestamp", "") - tool_formatted_timestamp = ( - format_timestamp(tool_timestamp) if tool_timestamp else "" - ) # Handle both custom types and Anthropic types item_type = getattr(tool_item, "type", None) @@ -2214,7 +2205,6 @@ def _render_messages( tool_template_message = TemplateMessage( message_type=tool_result.message_type, - formatted_timestamp=tool_formatted_timestamp, raw_timestamp=tool_timestamp, session_summary=session_summary, session_id=session_id, diff --git a/test/test_template_data.py b/test/test_template_data.py index d509495c..45ffd8cf 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -20,12 +20,11 @@ def test_template_message_creation(self): """Test creating a TemplateMessage with all fields.""" msg = TemplateMessage( message_type="user", - formatted_timestamp="2025-06-14 10:00:00", - raw_timestamp=None, + raw_timestamp="2025-06-14T10:00:00Z", ) assert msg.type == "user" - assert msg.formatted_timestamp == "2025-06-14 10:00:00" + assert msg.raw_timestamp == "2025-06-14T10:00:00Z" assert msg.message_title == "User" def test_template_message_title_capitalization(self): @@ -40,7 +39,6 @@ def test_template_message_title_capitalization(self): for msg_type, expected_display in test_cases: msg = TemplateMessage( message_type=msg_type, - formatted_timestamp="time", raw_timestamp=None, ) assert msg.message_title == expected_display @@ -377,8 +375,7 @@ def _create_message( """Helper to create a minimal TemplateMessage for testing.""" msg = TemplateMessage( message_type=msg_type, - formatted_timestamp="2025-06-14 10:00:00", - raw_timestamp=None, + raw_timestamp="2025-06-14T10:00:00Z", ) if msg_id: msg.message_id = msg_id @@ -529,32 +526,28 @@ def test_flatten_roundtrip_preserves_count(self): # Create a manual tree and verify flatten returns all messages root = TemplateMessage( message_type="session", - formatted_timestamp="2025-06-14 10:00:00", - raw_timestamp=None, + raw_timestamp="2025-06-14T10:00:00Z", ) root.message_id = "session-1" root.ancestry = [] user = TemplateMessage( message_type="user", - formatted_timestamp="2025-06-14 10:00:01", - raw_timestamp=None, + raw_timestamp="2025-06-14T10:00:01Z", ) user.message_id = "d-1" user.ancestry = ["session-1"] assistant = TemplateMessage( message_type="assistant", - formatted_timestamp="2025-06-14 10:00:02", - raw_timestamp=None, + raw_timestamp="2025-06-14T10:00:02Z", ) assistant.message_id = "d-2" assistant.ancestry = ["session-1", "d-1"] tool = TemplateMessage( message_type="tool_use", - formatted_timestamp="2025-06-14 10:00:03", - raw_timestamp=None, + raw_timestamp="2025-06-14T10:00:03Z", ) tool.message_id = "d-3" tool.ancestry = ["session-1", "d-1", "d-2"] From c41b24c8f79935373069c37047560ca8ee19c4da Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 00:09:07 +0100 Subject: [PATCH 07/57] Fix pyright error: parse_tool_input returns Optional[ToolInput] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change parse_tool_input to return None when no specialized parser exists, rather than returning dict[str, Any]. This is cleaner because: - ToolUseContent is already in the ToolInput union as the fallback - parse_tool_input returning None signals "use the fallback" - ToolUseContent.parsed_input now returns Optional[ToolInput] - When creating ToolUseMessage, we use `parsed or tool_use` Added a sentinel (_parsed_input_cached) to distinguish "not computed" from "computed but returned None". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 18 +++++++++++------- claude_code_log/parser.py | 10 ++++++---- claude_code_log/renderer.py | 4 ++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 828fa878..c0f7d95f 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -765,25 +765,29 @@ class ToolUseContent(BaseModel): id: str name: str input: dict[str, Any] - _parsed_input: Optional["ToolInput"] = PrivateAttr( - default=None - ) # Cached parsed input + _parsed_input_cached: bool = PrivateAttr(default=False) + _parsed_input: Optional["ToolInput"] = PrivateAttr(default=None) @property - def parsed_input(self) -> "ToolInput": - """Get typed input model if available, otherwise return raw dict. + def parsed_input(self) -> Optional["ToolInput"]: + """Get typed input model if available, None if no specialized parser. Lazily parses the input dict into a typed model. Uses strict validation first, then lenient parsing if available. Result is cached for subsequent accesses. + + Returns None when no specialized parser exists for this tool. + In that case, use this ToolUseContent itself as the fallback + (it's part of the ToolInput union). """ - if self._parsed_input is None: + if not self._parsed_input_cached: 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] + object.__setattr__(self, "_parsed_input_cached", True) + return self._parsed_input class ToolResultContent(BaseModel): diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 12bb9530..3ca184ae 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -704,7 +704,7 @@ def _parse_exitplanmode_lenient(data: dict[str, Any]) -> ExitPlanModeInput: } -def parse_tool_input(tool_name: str, input_data: dict[str, Any]) -> ToolInput: +def parse_tool_input(tool_name: str, input_data: dict[str, Any]) -> Optional[ToolInput]: """Parse tool input dictionary into a typed model. Uses strict validation first, then lenient parsing if available. @@ -714,7 +714,9 @@ def parse_tool_input(tool_name: str, input_data: dict[str, Any]) -> ToolInput: input_data: The raw input dictionary from the tool_use content Returns: - A typed input model if available, otherwise the original dictionary + A typed input model if parsing succeeds, None otherwise. + When None is returned, the caller should use ToolUseContent itself + as the fallback (it's part of the ToolInput union). """ model_class = TOOL_INPUT_MODELS.get(tool_name) if model_class is not None: @@ -725,8 +727,8 @@ def parse_tool_input(tool_name: str, input_data: dict[str, Any]) -> ToolInput: 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 + return None + return None # ============================================================================= diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index cd8cde8c..08d4c441 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -899,10 +899,10 @@ def _process_tool_use_item( tool_use_context[item_tool_use_id] = tool_use # Create ToolUseMessage wrapper with parsed input for specialized formatting - # Use ToolUseContent as fallback when no specialized parser (parsed_input returns dict) + # Use ToolUseContent as fallback when no specialized parser exists parsed = tool_use.parsed_input tool_use_message = ToolUseMessage( - input=parsed if not isinstance(parsed, dict) else tool_use, + input=parsed if parsed is not None else tool_use, tool_use_id=tool_use.id, tool_name=tool_use.name, ) From 780a062a024a711a6fcf806511e8fa1f22c72cdc Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 00:43:47 +0100 Subject: [PATCH 08/57] Simplify tool input parsing by removing cached property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove parsed_input property from ToolUseContent and call parse_tool_input directly in _process_tool_use_item. This eliminates the complexity of lazy caching with sentinels and makes the data flow more explicit. Changes: - Remove _parsed_input_cached, _parsed_input, and parsed_input property from ToolUseContent - Update format_tool_use_title to take (tool_name, parsed) params - Update get_tool_summary to take Optional[ToolInput] directly - Call parse_tool_input once in renderer, pass to both functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/tool_formatters.py | 29 +++++++++++++++---------- claude_code_log/models.py | 25 +-------------------- claude_code_log/renderer.py | 8 ++++--- 3 files changed, 23 insertions(+), 39 deletions(-) diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 7b96620c..76dc9493 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -37,6 +37,7 @@ ReadOutput, TaskInput, TodoWriteInput, + ToolInput, ToolResultContent, ToolUseContent, ToolUseMessage, @@ -550,14 +551,15 @@ def format_task_tool_content(task_input: TaskInput) -> str: # -- 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. +def get_tool_summary(parsed: Optional[ToolInput]) -> Optional[str]: + """Extract a one-line summary from parsed tool input 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 + to save vertical space. + Args: + parsed: Parsed tool input, or None if parsing failed/not available + """ if isinstance(parsed, BashInput): return parsed.description @@ -567,22 +569,25 @@ def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: if isinstance(parsed, TaskInput): return parsed.description if parsed.description else None - # No summary for other tools + # No summary for other tools or unparsed input return None -def format_tool_use_title(tool_use: ToolUseContent) -> str: +def format_tool_use_title(tool_name: str, parsed: Optional[ToolInput]) -> 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. + and optional summary/metadata. + + Args: + tool_name: The tool name (e.g., "Bash", "Read", "Edit") + parsed: Parsed tool input, or None if parsing failed/not available """ - escaped_name = escape_html(tool_use.name) - parsed = tool_use.parsed_input - summary = get_tool_summary(tool_use) + escaped_name = escape_html(tool_name) + summary = get_tool_summary(parsed) # TodoWrite: fixed title - if tool_use.name == "TodoWrite": + if tool_name == "TodoWrite": return "📝 Todo List" # Task: show subagent_type and description diff --git a/claude_code_log/models.py b/claude_code_log/models.py index c0f7d95f..3d0098b0 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Any, Union, Optional, Literal -from pydantic import BaseModel, PrivateAttr +from pydantic import BaseModel class MessageType(str, Enum): @@ -765,29 +765,6 @@ class ToolUseContent(BaseModel): id: str name: str input: dict[str, Any] - _parsed_input_cached: bool = PrivateAttr(default=False) - _parsed_input: Optional["ToolInput"] = PrivateAttr(default=None) - - @property - def parsed_input(self) -> Optional["ToolInput"]: - """Get typed input model if available, None if no specialized parser. - - Lazily parses the input dict into a typed model. - Uses strict validation first, then lenient parsing if available. - Result is cached for subsequent accesses. - - Returns None when no specialized parser exists for this tool. - In that case, use this ToolUseContent itself as the fallback - (it's part of the ToolInput union). - """ - if not self._parsed_input_cached: - from .parser import parse_tool_input - - object.__setattr__( - self, "_parsed_input", parse_tool_input(self.name, self.input) - ) - object.__setattr__(self, "_parsed_input_cached", True) - return self._parsed_input class ToolResultContent(BaseModel): diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 08d4c441..30c7e8d4 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -72,7 +72,7 @@ parse_command_output, parse_slash_command, ) -from .parser import parse_user_message_content +from .parser import parse_tool_input, parse_user_message_content # -- Content Formatters ------------------------------------------------------- @@ -889,8 +889,11 @@ def _process_tool_use_item( else: tool_use = tool_item + # Parse tool input once, use for both title and message content + parsed = parse_tool_input(tool_use.name, tool_use.input) + # Title is computed here but content formatting happens in HtmlRenderer - tool_message_title = format_tool_use_title(tool_use) + tool_message_title = format_tool_use_title(tool_use.name, parsed) escaped_id = escape_html(tool_use.id) item_tool_use_id = tool_use.id tool_title_hint = f"ID: {escaped_id}" @@ -900,7 +903,6 @@ def _process_tool_use_item( # Create ToolUseMessage wrapper with parsed input for specialized formatting # Use ToolUseContent as fallback when no specialized parser exists - parsed = tool_use.parsed_input tool_use_message = ToolUseMessage( input=parsed if parsed is not None else tool_use, tool_use_id=tool_use.id, From fd6ba29ab4e2d9e4f719074e2103d979cad04aa2 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 01:04:30 +0100 Subject: [PATCH 09/57] Refactor message parsing into dedicated parser modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract user and assistant message parsing from renderer.py into dedicated parser modules, following the pattern established by system_parser.py: - user_parser.py: is_* detection functions, process_* functions for user message variants (slash commands, bash input/output, etc.) - assistant_parser.py: process_assistant_message, process_thinking_item Changes: - Move is_command_message, is_local_command_output, is_bash_input, is_bash_output from parser.py to user_parser.py - Move _process_command_message, _process_local_command_output, _process_bash_input, _process_bash_output to user_parser.py - Extract user/assistant processing from _process_regular_message - Move _process_thinking_item logic to assistant_parser.py - Re-export is_* functions from parser.py for backward compatibility - Update renderer.py to use new parser modules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/assistant_parser.py | 78 ++++++++++ claude_code_log/parser.py | 26 +--- claude_code_log/renderer.py | 211 +++++++--------------------- claude_code_log/user_parser.py | 153 ++++++++++++++++++++ 4 files changed, 289 insertions(+), 179 deletions(-) create mode 100644 claude_code_log/assistant_parser.py create mode 100644 claude_code_log/user_parser.py diff --git a/claude_code_log/assistant_parser.py b/claude_code_log/assistant_parser.py new file mode 100644 index 00000000..a1113d4e --- /dev/null +++ b/claude_code_log/assistant_parser.py @@ -0,0 +1,78 @@ +"""Parser for assistant transcript entries. + +This module handles parsing of AssistantTranscriptEntry content into MessageContent subclasses: +- AssistantTextMessage: Claude's text responses +- ThinkingMessage: Extended thinking blocks +""" + +from typing import Optional + +from .models import ( + AssistantTextMessage, + ContentItem, + MessageContent, + ThinkingContent, + ThinkingMessage, +) + + +# ============================================================================= +# Message Processing Functions +# ============================================================================= + + +def process_assistant_message( + items: list[ContentItem], + is_sidechain: bool, +) -> tuple[bool, Optional[MessageContent], str, str]: + """Process assistant message and return (is_sidechain, content_model, message_type, message_title). + + Creates AssistantTextMessage from text/image content items. + + Args: + items: List of text/image content items (no tool_use, tool_result, thinking). + is_sidechain: Whether this is a sidechain message. + + Returns: + Tuple of (is_sidechain, content_model, message_type, message_title) + """ + message_title = "Assistant" + message_type = "assistant" + content_model: Optional[MessageContent] = None + + # Create AssistantTextMessage directly from items + # (empty text already filtered by chunk_message_content) + if items: + content_model = AssistantTextMessage( + items=items # type: ignore[arg-type] + ) + + if is_sidechain: + message_title = "Sub-assistant" + + return is_sidechain, content_model, message_type, message_title + + +def process_thinking_item( + tool_item: ContentItem, +) -> tuple[str, str, Optional[MessageContent]]: + """Process a thinking content item. + + Args: + tool_item: ThinkingContent or compatible object with 'thinking' attribute + + Returns: + Tuple of (message_type, message_title, 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 = ThinkingMessage(thinking=thinking_text, signature=signature) + + return "thinking", "Thinking", thinking_model diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 3ca184ae..e5294354 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -460,6 +460,12 @@ def parse_user_message_content( # Message Type Detection # ============================================================================= +# Re-export from user_parser for backward compatibility +from .user_parser import is_bash_input as is_bash_input +from .user_parser import is_bash_output as is_bash_output +from .user_parser import is_command_message as is_command_message +from .user_parser import is_local_command_output as is_local_command_output + def is_system_message(text_content: str) -> bool: """Check if a message is a system message that should be filtered out.""" @@ -472,26 +478,6 @@ def is_system_message(text_content: str) -> bool: 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. diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 30c7e8d4..08b0d449 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -35,7 +35,6 @@ SlashCommandMessage, SystemMessage, UnknownMessage, - UserMemoryMessage, UserSlashCommandMessage, UserSteeringMessage, UserTextMessage, @@ -44,10 +43,21 @@ as_assistant_entry, as_user_entry, extract_text_content, +) +from .user_parser import ( is_bash_input, is_bash_output, is_command_message, is_local_command_output, + process_bash_input, + process_bash_output, + process_command_message, + process_local_command_output, + process_user_message, +) +from .assistant_parser import ( + process_assistant_message, + process_thinking_item, ) from .utils import ( format_timestamp, @@ -67,12 +77,8 @@ 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_tool_input, parse_user_message_content +from .parser import parse_tool_input # -- Content Formatters ------------------------------------------------------- @@ -625,119 +631,10 @@ def prepare_session_navigation( # -- 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]: -# """Process a summary message and return (css_class, content_html, message_type).""" -# css_class = "summary" -# content_html = f"Summary: {escape_html(str(message.summary))}" -# message_type = "summary" -# return css_class, content_html, message_type - - -def _process_command_message( - text_content: str, -) -> tuple[Optional["MessageContent"], str, str]: - """Process a slash command message and return (content, message_type, message_title). - - These are user messages containing slash command invocations (e.g., /context, /model). - The JSONL type is "user", not "system". - """ - # 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 - - return content, "user", "Slash Command" - - -def _process_local_command_output( - text_content: str, -) -> tuple[Optional["MessageContent"], str, str]: - """Process slash command output and return (content, message_type, message_title). - - These are user messages containing the output from slash commands (e.g., /context, /model). - The JSONL type is "user", not "system". - """ - # 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 - - return content, "user", "" - - -def _process_bash_input( - text_content: str, -) -> tuple[Optional["MessageContent"], str, str]: - """Process bash input command and return (content, message_type, message_title).""" - # 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 - - return content, "bash-input", "Bash command" - - -def _process_bash_output( - text_content: str, -) -> tuple[Optional["MessageContent"], str, str]: - """Process bash output and return (content, message_type, message_title).""" - # 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 - - return content, "bash-output", "" - - -def _process_regular_message( - items: list[ContentItem], - message_type: str, - is_sidechain: bool, - is_meta: bool = False, -) -> tuple[bool, Optional["MessageContent"], str, str]: - """Process regular message and return (is_sidechain, 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. - - Args: - items: List of text/image content items (no tool_use, tool_result, thinking). - is_meta: True for slash command expanded prompts (isMeta=True in JSONL) - """ - message_title = message_type.title() # Default title - content_model: Optional["MessageContent"] = None - - # Handle user-specific preprocessing - if message_type == MessageType.USER: - # Note: sidechain user messages are skipped before reaching this function - # Parse user content (is_meta triggers UserSlashCommandMessage creation) - content_model = parse_user_message_content(items, is_slash_command=is_meta) - - # Determine message_title from content type - if isinstance(content_model, UserSlashCommandMessage): - message_title = "User (slash command)" - elif isinstance(content_model, CompactedSummaryMessage): - message_title = "User (compacted conversation)" - elif isinstance(content_model, UserMemoryMessage): - message_title = "Memory" - - elif message_type == MessageType.ASSISTANT: - # Create AssistantTextMessage directly from items - # (empty text already filtered by chunk_message_content) - if items: - content_model = AssistantTextMessage( - items=items # type: ignore[arg-type] - ) - - if is_sidechain: - # Update message title for display (only non-user types reach here) - if not isinstance(content_model, CompactedSummaryMessage): - message_title = "Sub-assistant" - - return is_sidechain, content_model, message_type, message_title +# Note: Message parsing functions have been moved to dedicated parser modules: +# - user_parser.py: process_user_message, process_command_message, etc. +# - assistant_parser.py: process_assistant_message, process_thinking_item +# - system_parser.py: parse_system_transcript def _process_system_message( @@ -997,30 +894,6 @@ def _process_tool_result_item( ) -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 = ThinkingMessage(thinking=thinking_text, signature=signature) - - return ToolItemResult( - message_type="thinking", - message_title="Thinking", - content=thinking_model, - ) - - # -- Message Pairing ---------------------------------------------------------- @@ -2073,19 +1946,19 @@ def _render_messages( if is_command: content_model, chunk_message_type, message_title = ( - _process_command_message(chunk_text) + process_command_message(chunk_text) ) elif is_local_output: content_model, chunk_message_type, message_title = ( - _process_local_command_output(chunk_text) + process_local_command_output(chunk_text) ) elif is_bash_cmd: content_model, chunk_message_type, message_title = ( - _process_bash_input(chunk_text) + process_bash_input(chunk_text) ) elif is_bash_result: content_model, chunk_message_type, message_title = ( - _process_bash_output(chunk_text) + process_bash_output(chunk_text) ) else: # For queue-operation messages, treat them as user messages @@ -2094,17 +1967,32 @@ def _render_messages( else: effective_type = message_type - ( - chunk_is_sidechain, - content_model, - chunk_message_type, - message_title, - ) = _process_regular_message( - chunk, # Pass the chunk items - effective_type, - chunk_is_sidechain, - getattr(message, "isMeta", False), - ) + # Dispatch to user or assistant parser based on message type + if effective_type == MessageType.USER: + ( + chunk_is_sidechain, + content_model, + chunk_message_type, + message_title, + ) = process_user_message( + chunk, # Pass the chunk items + chunk_is_sidechain, + getattr(message, "isMeta", False), + ) + elif effective_type == MessageType.ASSISTANT: + ( + chunk_is_sidechain, + content_model, + chunk_message_type, + message_title, + ) = process_assistant_message( + chunk, # Pass the chunk items + chunk_is_sidechain, + ) + else: + # Fallback for unknown types + message_title = effective_type.title() + chunk_message_type = effective_type # Convert to UserSteeringMessage for queue-operation 'remove' messages if ( @@ -2178,7 +2066,12 @@ def _render_messages( ): tool_result = _process_tool_result_item(tool_item, tool_use_context) elif isinstance(tool_item, ThinkingContent) or item_type == "thinking": - tool_result = _process_thinking_item(tool_item) + msg_type, msg_title, content = process_thinking_item(tool_item) + tool_result = ToolItemResult( + message_type=msg_type, + message_title=msg_title, + content=content, + ) else: # Handle unknown content types tool_result = ToolItemResult( diff --git a/claude_code_log/user_parser.py b/claude_code_log/user_parser.py new file mode 100644 index 00000000..c4c02cf1 --- /dev/null +++ b/claude_code_log/user_parser.py @@ -0,0 +1,153 @@ +"""Parser for user transcript entries. + +This module handles parsing of UserTranscriptEntry content into MessageContent subclasses: +- SlashCommandMessage: Slash command invocations +- CommandOutputMessage: Local command output +- BashInputMessage: Bash command input +- BashOutputMessage: Bash command output +- UserTextMessage: Regular user text (with optional IDE notifications) +- UserSlashCommandMessage: Expanded slash command prompts (isMeta) +- CompactedSummaryMessage: Compacted conversation summaries +- UserMemoryMessage: User memory content +- UserSteeringMessage: User steering prompts (queue-operation 'remove') +""" + +from typing import Optional + +from .models import ( + CompactedSummaryMessage, + ContentItem, + MessageContent, + UserMemoryMessage, + UserSlashCommandMessage, +) +from .parser import ( + parse_bash_input, + parse_bash_output, + parse_command_output, + parse_slash_command, + parse_user_message_content, +) + + +# ============================================================================= +# Message Type Detection +# ============================================================================= + + +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 + + +# ============================================================================= +# Message Processing Functions +# ============================================================================= + + +def process_command_message( + text_content: str, +) -> tuple[Optional[MessageContent], str, str]: + """Process a slash command message and return (content, message_type, message_title). + + These are user messages containing slash command invocations (e.g., /context, /model). + The JSONL type is "user", not "system". + """ + # 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 + + return content, "user", "Slash Command" + + +def process_local_command_output( + text_content: str, +) -> tuple[Optional[MessageContent], str, str]: + """Process slash command output and return (content, message_type, message_title). + + These are user messages containing the output from slash commands (e.g., /context, /model). + The JSONL type is "user", not "system". + """ + # 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 + + return content, "user", "" + + +def process_bash_input( + text_content: str, +) -> tuple[Optional[MessageContent], str, str]: + """Process bash input command and return (content, message_type, message_title).""" + # 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 + + return content, "bash-input", "Bash command" + + +def process_bash_output( + text_content: str, +) -> tuple[Optional[MessageContent], str, str]: + """Process bash output and return (content, message_type, message_title).""" + # 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 + + return content, "bash-output", "" + + +def process_user_message( + items: list[ContentItem], + is_sidechain: bool, + is_meta: bool = False, +) -> tuple[bool, Optional[MessageContent], str, str]: + """Process user message and return (is_sidechain, content_model, message_type, message_title). + + Handles user-specific content types: + - UserSlashCommandMessage (from isMeta=True) + - CompactedSummaryMessage + - UserMemoryMessage + - Regular UserTextMessage + + Note: Sidechain user messages (Sub-assistant prompts) are skipped earlier + in the main processing loop since they duplicate the Task tool input prompt. + + Args: + items: List of text/image content items (no tool_use, tool_result, thinking). + is_sidechain: Whether this is a sidechain message. + is_meta: True for slash command expanded prompts (isMeta=True in JSONL) + + Returns: + Tuple of (is_sidechain, content_model, message_type, message_title) + """ + message_title = "User" # Default title + message_type = "user" + + # Parse user content (is_meta triggers UserSlashCommandMessage creation) + content_model = parse_user_message_content(items, is_slash_command=is_meta) + + # Determine message_title from content type + if isinstance(content_model, UserSlashCommandMessage): + message_title = "User (slash command)" + elif isinstance(content_model, CompactedSummaryMessage): + message_title = "User (compacted conversation)" + elif isinstance(content_model, UserMemoryMessage): + message_title = "Memory" + + return is_sidechain, content_model, message_type, message_title From cf58954699f395063ad403280494288874eef310 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 12:17:36 +0100 Subject: [PATCH 10/57] Add message_title/type to content models, consolidate parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add message_title() method and message_type property to all MessageContent subclasses in models.py, enabling type/title to be obtained directly from the content model - Move parsing functions from parser.py to user_parser.py: parse_slash_command, parse_command_output, parse_bash_input, parse_bash_output, parse_ide_notifications, parse_compacted_summary, parse_user_memory, parse_user_message_content - Simplify assistant_parser.py: rename process_* to parse_* functions that return just content models - Update renderer.py to get message_type/title from content models - Use empty string "" for message_title on CommandOutputMessage and BashOutputMessage to suppress title display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/assistant_parser.py | 37 +-- claude_code_log/html/__init__.py | 2 +- claude_code_log/models.py | 73 ++++- claude_code_log/parser.py | 366 +----------------------- claude_code_log/renderer.py | 80 +++--- claude_code_log/user_parser.py | 417 +++++++++++++++++++++++----- claude_code_log/utils.py | 4 +- test/test_ide_tags.py | 7 +- test/test_user_renderer.py | 2 +- test/test_utils.py | 6 +- 10 files changed, 474 insertions(+), 520 deletions(-) diff --git a/claude_code_log/assistant_parser.py b/claude_code_log/assistant_parser.py index a1113d4e..ea182b6f 100644 --- a/claude_code_log/assistant_parser.py +++ b/claude_code_log/assistant_parser.py @@ -10,59 +10,48 @@ from .models import ( AssistantTextMessage, ContentItem, - MessageContent, ThinkingContent, ThinkingMessage, ) # ============================================================================= -# Message Processing Functions +# Message Parsing Functions # ============================================================================= -def process_assistant_message( +def parse_assistant_message_content( items: list[ContentItem], - is_sidechain: bool, -) -> tuple[bool, Optional[MessageContent], str, str]: - """Process assistant message and return (is_sidechain, content_model, message_type, message_title). +) -> Optional[AssistantTextMessage]: + """Parse assistant message content into AssistantTextMessage. Creates AssistantTextMessage from text/image content items. Args: items: List of text/image content items (no tool_use, tool_result, thinking). - is_sidechain: Whether this is a sidechain message. Returns: - Tuple of (is_sidechain, content_model, message_type, message_title) + AssistantTextMessage if items is non-empty, None otherwise. """ - message_title = "Assistant" - message_type = "assistant" - content_model: Optional[MessageContent] = None - # Create AssistantTextMessage directly from items # (empty text already filtered by chunk_message_content) if items: - content_model = AssistantTextMessage( + return AssistantTextMessage( items=items # type: ignore[arg-type] ) + return None - if is_sidechain: - message_title = "Sub-assistant" - - return is_sidechain, content_model, message_type, message_title - -def process_thinking_item( +def parse_thinking_item( tool_item: ContentItem, -) -> tuple[str, str, Optional[MessageContent]]: - """Process a thinking content item. +) -> ThinkingMessage: + """Parse a thinking content item into ThinkingMessage. Args: tool_item: ThinkingContent or compatible object with 'thinking' attribute Returns: - Tuple of (message_type, message_title, content_model) + ThinkingMessage containing the thinking text and optional signature. """ # Extract thinking text from the content item if isinstance(tool_item, ThinkingContent): @@ -73,6 +62,4 @@ def process_thinking_item( signature = None # Create the content model (formatting happens in HtmlRenderer) - thinking_model = ThinkingMessage(thinking=thinking_text, signature=signature) - - return "thinking", "Thinking", thinking_model + return ThinkingMessage(thinking=thinking_text, signature=signature) diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py index 50cb738f..430a9802 100644 --- a/claude_code_log/html/__init__.py +++ b/claude_code_log/html/__init__.py @@ -59,7 +59,7 @@ UserMemoryMessage, UserTextMessage, ) -from ..parser import ( +from ..user_parser import ( parse_bash_input, parse_bash_output, parse_command_output, diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 3d0098b0..f717817b 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -158,6 +158,13 @@ class SlashCommandMessage(MessageContent): command_args: str command_contents: str + @property + def message_type(self) -> str: + return "user" + + def message_title(self) -> Optional[str]: + return "Slash Command" + @dataclass class CommandOutputMessage(MessageContent): @@ -169,6 +176,13 @@ class CommandOutputMessage(MessageContent): stdout: str is_markdown: bool # True if content appears to be markdown + @property + def message_type(self) -> str: + return "user" + + def message_title(self) -> Optional[str]: + return "" # Empty title for command output + @dataclass class BashInputMessage(MessageContent): @@ -179,6 +193,13 @@ class BashInputMessage(MessageContent): command: str + @property + def message_type(self) -> str: + return "bash-input" + + def message_title(self) -> Optional[str]: + return "Bash command" + @dataclass class BashOutputMessage(MessageContent): @@ -190,6 +211,13 @@ class BashOutputMessage(MessageContent): stdout: Optional[str] = None # Raw stdout content (may contain ANSI codes) stderr: Optional[str] = None # Raw stderr content (may contain ANSI codes) + @property + def message_type(self) -> str: + return "bash-output" + + def message_title(self) -> Optional[str]: + return "" # Empty title for bash output + @dataclass class ToolResultMessage(MessageContent): @@ -233,6 +261,13 @@ class CompactedSummaryMessage(MessageContent): summary_text: str + @property + def message_type(self) -> str: + return "user" + + def message_title(self) -> Optional[str]: + return "User (compacted conversation)" + @dataclass class UserMemoryMessage(MessageContent): @@ -245,6 +280,13 @@ class UserMemoryMessage(MessageContent): memory_text: str + @property + def message_type(self) -> str: + return "user" + + def message_title(self) -> Optional[str]: + return "Memory" + @dataclass class UserSlashCommandMessage(MessageContent): @@ -257,6 +299,13 @@ class UserSlashCommandMessage(MessageContent): text: str + @property + def message_type(self) -> str: + return "user" + + def message_title(self) -> Optional[str]: + return "User (slash command)" + @dataclass class IdeOpenedFile: @@ -351,6 +400,13 @@ class UserTextMessage(MessageContent): TextContent | ImageContent | IdeNotificationContent ] = field(default_factory=list) + @property + def message_type(self) -> str: + return "user" + + def message_title(self) -> Optional[str]: + return "User" + @dataclass class UserSteeringMessage(UserTextMessage): @@ -360,7 +416,8 @@ class UserSteeringMessage(UserTextMessage): items from the queue. Inherits from UserTextMessage. """ - pass + def message_title(self) -> Optional[str]: + return "User (steering)" # ============================================================================= @@ -389,6 +446,13 @@ class AssistantTextMessage(MessageContent): TextContent | ImageContent ] = field(default_factory=list) + @property + def message_type(self) -> str: + return "assistant" + + def message_title(self) -> Optional[str]: + return "Assistant" + @dataclass class ThinkingMessage(MessageContent): @@ -404,6 +468,13 @@ class ThinkingMessage(MessageContent): thinking: str signature: Optional[str] = None + @property + def message_type(self) -> str: + return "thinking" + + def message_title(self) -> Optional[str]: + return "Thinking" + @dataclass class UnknownMessage(MessageContent): diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index e5294354..c50fe614 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -1,9 +1,7 @@ #!/usr/bin/env python3 """Parse and extract data from Claude transcript JSONL files.""" -import json -import re -from typing import Any, Callable, Optional, Union, cast +from typing import Any, Callable, Optional, cast from datetime import datetime from pydantic import BaseModel @@ -19,19 +17,6 @@ ToolUseContent, ToolResultContent, ImageContent, - # User message content models - SlashCommandMessage, - CommandOutputMessage, - BashInputMessage, - BashOutputMessage, - CompactedSummaryMessage, - UserMemoryMessage, - UserSlashCommandMessage, - UserTextMessage, - IdeNotificationContent, - IdeOpenedFile, - IdeSelection, - IdeDiagnostic, # Assistant message content models BashInput, ReadInput, @@ -113,359 +98,10 @@ def parse_timestamp(timestamp_str: str) -> Optional[datetime]: return None -# ============================================================================= -# User Message Content Parsing -# ============================================================================= - - -def parse_slash_command(text: str) -> Optional[SlashCommandMessage]: - """Parse slash command tags from text. - - Args: - text: Raw text that may contain command-name, command-args, command-contents tags - - Returns: - SlashCommandMessage 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 SlashCommandMessage( - command_name=command_name, - command_args=command_args, - command_contents=command_contents, - ) - - -def parse_command_output(text: str) -> Optional[CommandOutputMessage]: - """Parse command output tags from text. - - Args: - text: Raw text that may contain local-command-stdout tags - - Returns: - CommandOutputMessage 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)) - - return CommandOutputMessage(stdout=stdout_content, is_markdown=is_markdown) - - -def parse_bash_input(text: str) -> Optional[BashInputMessage]: - """Parse bash input tags from text. - - Args: - text: Raw text that may contain bash-input tags - - Returns: - BashInputMessage if tags found, None otherwise - """ - bash_match = re.search(r"(.*?)", text, re.DOTALL) - if not bash_match: - return None - - return BashInputMessage(command=bash_match.group(1).strip()) - - -def parse_bash_output(text: str) -> Optional[BashOutputMessage]: - """Parse bash output tags from text. - - Args: - text: Raw text that may contain bash-stdout/bash-stderr tags - - Returns: - BashOutputMessage if tags found, None otherwise - """ - 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 BashOutputMessage(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)) - - 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[CompactedSummaryMessage]: - """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 CompactedSummaryMessage. - - Args: - content_list: List of ContentItem from user message - - Returns: - CompactedSummaryMessage 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 CompactedSummaryMessage(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[UserMemoryMessage]: - """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: - UserMemoryMessage if tag found, None otherwise - """ - match = USER_MEMORY_PATTERN.search(text) - if match: - memory_content = match.group(1).strip() - return UserMemoryMessage(memory_text=memory_content) - return None - - -# Type alias for content models returned by parse_user_message_content -UserMessageContent = Union[ - CompactedSummaryMessage, UserMemoryMessage, UserSlashCommandMessage, UserTextMessage -] - - -def parse_user_message_content( - content_list: list[ContentItem], - is_slash_command: bool = False, -) -> 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: - - UserSlashCommandMessage: Slash command expanded prompts (isMeta=True) - - CompactedSummaryMessage: Session continuation summaries - - UserMemoryMessage: User memory input from CLAUDE.md - - UserTextMessage: Normal user text with optional IDE notifications and images - - This function processes content items preserving their original order: - - TextContent items have IDE notifications extracted, producing - [IdeNotificationContent, TextContent] pairs - - ImageContent items are preserved as-is - - Args: - content_list: List of ContentItem from user message - is_slash_command: True for slash command expanded prompts (isMeta=True) - - Returns: - A content model, or None if content_list is empty. - """ - if not content_list: - return None - - # Slash command expanded prompts - combine all text as markdown - if is_slash_command: - all_text = "\n\n".join( - getattr(item, "text", "") for item in content_list if hasattr(item, "text") - ) - return UserSlashCommandMessage(text=all_text) if all_text else None - - # Get first text item for special case detection - first_text_item = next( - (item for item in content_list if hasattr(item, "text")), - None, - ) - first_text = getattr(first_text_item, "text", "") if first_text_item else "" - - # Check for compacted session summary first (handles text combining internally) - compacted = parse_compacted_summary(content_list) - if compacted: - return compacted - - # Check for user memory input - user_memory = parse_user_memory(first_text) - if user_memory: - return user_memory - - # Build items list preserving order, extracting IDE notifications from text - items: list[TextContent | ImageContent | IdeNotificationContent] = [] - - for item in content_list: - # Check for text content - if hasattr(item, "text"): - item_text: str = getattr(item, "text") # type: ignore[assignment] - ide_content = parse_ide_notifications(item_text) - - if ide_content: - # Add IDE notification item first - items.append(ide_content) - remaining_text: str = ide_content.remaining_text - else: - remaining_text = item_text - - # Add remaining text as TextContent if non-empty - if remaining_text.strip(): - items.append(TextContent(type="text", text=remaining_text)) - elif isinstance(item, ImageContent): - # ImageContent model - use as-is - items.append(item) - elif hasattr(item, "source") and getattr(item, "type", None) == "image": - # Anthropic ImageContent - convert to our model - items.append(ImageContent.model_validate(item.model_dump())) # type: ignore[union-attr] - - # Return UserTextMessage with items list - return UserTextMessage(items=items) - - # ============================================================================= # Message Type Detection # ============================================================================= -# Re-export from user_parser for backward compatibility -from .user_parser import is_bash_input as is_bash_input -from .user_parser import is_bash_output as is_bash_output -from .user_parser import is_command_message as is_command_message -from .user_parser import is_local_command_output as is_local_command_output - def is_system_message(text_content: str) -> bool: """Check if a message is a system message that should be filtered out.""" diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 08b0d449..d4e4478b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -49,15 +49,15 @@ is_bash_output, is_command_message, is_local_command_output, - process_bash_input, - process_bash_output, - process_command_message, - process_local_command_output, - process_user_message, + parse_bash_input, + parse_bash_output, + parse_command_output, + parse_slash_command, + parse_user_message_content, ) from .assistant_parser import ( - process_assistant_message, - process_thinking_item, + parse_assistant_message_content, + parse_thinking_item, ) from .utils import ( format_timestamp, @@ -632,8 +632,8 @@ def prepare_session_navigation( # -- Message Processing Functions --------------------------------------------- # Note: Message parsing functions have been moved to dedicated parser modules: -# - user_parser.py: process_user_message, process_command_message, etc. -# - assistant_parser.py: process_assistant_message, process_thinking_item +# - user_parser.py: parse_user_message_content, parse_slash_command, etc. +# - assistant_parser.py: parse_assistant_message_content, parse_thinking_item # - system_parser.py: parse_system_transcript @@ -1945,21 +1945,13 @@ def _render_messages( chunk_is_sidechain = getattr(message, "isSidechain", False) if is_command: - content_model, chunk_message_type, message_title = ( - process_command_message(chunk_text) - ) + content_model = parse_slash_command(chunk_text) elif is_local_output: - content_model, chunk_message_type, message_title = ( - process_local_command_output(chunk_text) - ) + content_model = parse_command_output(chunk_text) elif is_bash_cmd: - content_model, chunk_message_type, message_title = ( - process_bash_input(chunk_text) - ) + content_model = parse_bash_input(chunk_text) elif is_bash_result: - content_model, chunk_message_type, message_title = ( - process_bash_output(chunk_text) - ) + content_model = parse_bash_output(chunk_text) else: # For queue-operation messages, treat them as user messages if isinstance(message, QueueOperationTranscriptEntry): @@ -1969,30 +1961,12 @@ def _render_messages( # Dispatch to user or assistant parser based on message type if effective_type == MessageType.USER: - ( - chunk_is_sidechain, - content_model, - chunk_message_type, - message_title, - ) = process_user_message( + content_model = parse_user_message_content( chunk, # Pass the chunk items - chunk_is_sidechain, - getattr(message, "isMeta", False), + is_slash_command=getattr(message, "isMeta", False), ) elif effective_type == MessageType.ASSISTANT: - ( - chunk_is_sidechain, - content_model, - chunk_message_type, - message_title, - ) = process_assistant_message( - chunk, # Pass the chunk items - chunk_is_sidechain, - ) - else: - # Fallback for unknown types - message_title = effective_type.title() - chunk_message_type = effective_type + content_model = parse_assistant_message_content(chunk) # Convert to UserSteeringMessage for queue-operation 'remove' messages if ( @@ -2001,7 +1975,21 @@ def _render_messages( and isinstance(content_model, UserTextMessage) ): content_model = UserSteeringMessage(items=content_model.items) - message_title = "User (steering)" + + # Get message_type and message_title from content_model + if content_model is not None: + chunk_message_type = content_model.message_type + message_title = content_model.message_title() + # Override for sidechain assistant messages + if chunk_is_sidechain and isinstance( + content_model, AssistantTextMessage + ): + message_title = "Sub-assistant" + else: + # Fallback for unknown/empty content + # MessageType inherits from str, so we can use it directly + chunk_message_type = str(message_type) + message_title = chunk_message_type.title() # Skip empty chunks if not chunk: @@ -2066,10 +2054,10 @@ def _render_messages( ): tool_result = _process_tool_result_item(tool_item, tool_use_context) elif isinstance(tool_item, ThinkingContent) or item_type == "thinking": - msg_type, msg_title, content = process_thinking_item(tool_item) + content = parse_thinking_item(tool_item) tool_result = ToolItemResult( - message_type=msg_type, - message_title=msg_title, + message_type=content.message_type, + message_title=content.message_title() or "Thinking", content=content, ) else: diff --git a/claude_code_log/user_parser.py b/claude_code_log/user_parser.py index c4c02cf1..42a418c0 100644 --- a/claude_code_log/user_parser.py +++ b/claude_code_log/user_parser.py @@ -12,21 +12,26 @@ - UserSteeringMessage: User steering prompts (queue-operation 'remove') """ -from typing import Optional +import json +import re +from typing import Any, Optional, Union, cast from .models import ( + BashInputMessage, + BashOutputMessage, + CommandOutputMessage, CompactedSummaryMessage, ContentItem, - MessageContent, + IdeDiagnostic, + IdeNotificationContent, + IdeOpenedFile, + IdeSelection, + ImageContent, + SlashCommandMessage, + TextContent, UserMemoryMessage, UserSlashCommandMessage, -) -from .parser import ( - parse_bash_input, - parse_bash_output, - parse_command_output, - parse_slash_command, - parse_user_message_content, + UserTextMessage, ) @@ -56,98 +61,360 @@ def is_bash_output(text_content: str) -> bool: # ============================================================================= -# Message Processing Functions +# Slash Command Parsing # ============================================================================= -def process_command_message( - text_content: str, -) -> tuple[Optional[MessageContent], str, str]: - """Process a slash command message and return (content, message_type, message_title). +def parse_slash_command(text: str) -> Optional[SlashCommandMessage]: + """Parse slash command tags from text. + + Args: + text: Raw text that may contain command-name, command-args, command-contents tags + + Returns: + SlashCommandMessage 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 SlashCommandMessage( + command_name=command_name, + command_args=command_args, + command_contents=command_contents, + ) + + +def parse_command_output(text: str) -> Optional[CommandOutputMessage]: + """Parse command output tags from text. + + Args: + text: Raw text that may contain local-command-stdout tags - These are user messages containing slash command invocations (e.g., /context, /model). - The JSONL type is "user", not "system". + Returns: + CommandOutputMessage if tags found, None otherwise """ - # 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, + 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)) + + return CommandOutputMessage(stdout=stdout_content, is_markdown=is_markdown) + + +# ============================================================================= +# Bash Input/Output Parsing +# ============================================================================= + + +def parse_bash_input(text: str) -> Optional[BashInputMessage]: + """Parse bash input tags from text. + + Args: + text: Raw text that may contain bash-input tags + + Returns: + BashInputMessage if tags found, None otherwise + """ + bash_match = re.search(r"(.*?)", text, re.DOTALL) + if not bash_match: + return None + + return BashInputMessage(command=bash_match.group(1).strip()) + + +def parse_bash_output(text: str) -> Optional[BashOutputMessage]: + """Parse bash output tags from text. + + Args: + text: Raw text that may contain bash-stdout/bash-stderr tags + + Returns: + BashOutputMessage if tags found, None otherwise + """ + 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 - return content, "user", "Slash Command" + 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 -def process_local_command_output( - text_content: str, -) -> tuple[Optional[MessageContent], str, str]: - """Process slash command output and return (content, message_type, message_title). + return BashOutputMessage(stdout=stdout, stderr=stderr) - These are user messages containing the output from slash commands (e.g., /context, /model). - The JSONL type is "user", not "system". + +# ============================================================================= +# IDE Notification Parsing +# ============================================================================= + +# 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 """ - # 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 + 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)) + + 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(), + ) - return content, "user", "" +# ============================================================================= +# Compacted Summary and User Memory Parsing +# ============================================================================= -def process_bash_input( - text_content: str, -) -> tuple[Optional[MessageContent], str, str]: - """Process bash input command and return (content, message_type, message_title).""" - # 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 +# Pattern for compacted session summary detection +COMPACTED_SUMMARY_PREFIX = "This session is being continued from a previous conversation that ran out of context" - return content, "bash-input", "Bash command" +def parse_compacted_summary( + content_list: list[ContentItem], +) -> Optional[CompactedSummaryMessage]: + """Parse compacted session summary from content list. -def process_bash_output( - text_content: str, -) -> tuple[Optional[MessageContent], str, str]: - """Process bash output and return (content, message_type, message_title).""" - # 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 + Compacted summaries are generated when a session runs out of context and + needs to be continued. They contain a summary of the previous conversation. - return content, "bash-output", "" + If the first text item starts with the compacted summary prefix, all text + items are combined into a single CompactedSummaryMessage. + Args: + content_list: List of ContentItem from user message + + Returns: + CompactedSummaryMessage 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 CompactedSummaryMessage(summary_text=all_text) + + +# Pattern for user memory input tag +USER_MEMORY_PATTERN = re.compile( + r"(.*?)", re.DOTALL +) -def process_user_message( - items: list[ContentItem], - is_sidechain: bool, - is_meta: bool = False, -) -> tuple[bool, Optional[MessageContent], str, str]: - """Process user message and return (is_sidechain, content_model, message_type, message_title). - Handles user-specific content types: - - UserSlashCommandMessage (from isMeta=True) - - CompactedSummaryMessage - - UserMemoryMessage - - Regular UserTextMessage +def parse_user_memory(text: str) -> Optional[UserMemoryMessage]: + """Parse user memory input tag from text. - Note: Sidechain user messages (Sub-assistant prompts) are skipped earlier - in the main processing loop since they duplicate the Task tool input prompt. + User memory input contains context that the user has provided from + their CLAUDE.md or other memory sources. Args: - items: List of text/image content items (no tool_use, tool_result, thinking). - is_sidechain: Whether this is a sidechain message. - is_meta: True for slash command expanded prompts (isMeta=True in JSONL) + text: Raw text that may contain user memory input tag Returns: - Tuple of (is_sidechain, content_model, message_type, message_title) + UserMemoryMessage if tag found, None otherwise """ - message_title = "User" # Default title - message_type = "user" + match = USER_MEMORY_PATTERN.search(text) + if match: + memory_content = match.group(1).strip() + return UserMemoryMessage(memory_text=memory_content) + return None + + +# ============================================================================= +# User Message Content Parsing +# ============================================================================= - # Parse user content (is_meta triggers UserSlashCommandMessage creation) - content_model = parse_user_message_content(items, is_slash_command=is_meta) +# Type alias for content models returned by parse_user_message_content +UserMessageContent = Union[ + CompactedSummaryMessage, UserMemoryMessage, UserSlashCommandMessage, UserTextMessage +] - # Determine message_title from content type - if isinstance(content_model, UserSlashCommandMessage): - message_title = "User (slash command)" - elif isinstance(content_model, CompactedSummaryMessage): - message_title = "User (compacted conversation)" - elif isinstance(content_model, UserMemoryMessage): - message_title = "Memory" - return is_sidechain, content_model, message_type, message_title +def parse_user_message_content( + content_list: list[ContentItem], + is_slash_command: bool = False, +) -> 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: + - UserSlashCommandMessage: Slash command expanded prompts (isMeta=True) + - CompactedSummaryMessage: Session continuation summaries + - UserMemoryMessage: User memory input from CLAUDE.md + - UserTextMessage: Normal user text with optional IDE notifications and images + + This function processes content items preserving their original order: + - TextContent items have IDE notifications extracted, producing + [IdeNotificationContent, TextContent] pairs + - ImageContent items are preserved as-is + + Args: + content_list: List of ContentItem from user message + is_slash_command: True for slash command expanded prompts (isMeta=True) + + Returns: + A content model, or None if content_list is empty. + """ + if not content_list: + return None + + # Slash command expanded prompts - combine all text as markdown + if is_slash_command: + all_text = "\n\n".join( + getattr(item, "text", "") for item in content_list if hasattr(item, "text") + ) + return UserSlashCommandMessage(text=all_text) if all_text else None + + # Get first text item for special case detection + first_text_item = next( + (item for item in content_list if hasattr(item, "text")), + None, + ) + first_text = getattr(first_text_item, "text", "") if first_text_item else "" + + # Check for compacted session summary first (handles text combining internally) + compacted = parse_compacted_summary(content_list) + if compacted: + return compacted + + # Check for user memory input + user_memory = parse_user_memory(first_text) + if user_memory: + return user_memory + + # Build items list preserving order, extracting IDE notifications from text + items: list[TextContent | ImageContent | IdeNotificationContent] = [] + + for item in content_list: + # Check for text content + if hasattr(item, "text"): + item_text: str = getattr(item, "text") # type: ignore[assignment] + ide_content = parse_ide_notifications(item_text) + + if ide_content: + # Add IDE notification item first + items.append(ide_content) + remaining_text: str = ide_content.remaining_text + else: + remaining_text = item_text + + # Add remaining text as TextContent if non-empty + if remaining_text.strip(): + items.append(TextContent(type="text", text=remaining_text)) + elif isinstance(item, ImageContent): + # ImageContent model - use as-is + items.append(item) + elif hasattr(item, "source") and getattr(item, "type", None) == "image": + # Anthropic ImageContent - convert to our model + items.append(ImageContent.model_validate(item.model_dump())) # type: ignore[union-attr] + + # Return UserTextMessage with items list + return UserTextMessage(items=items) diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index e0ad25fd..b89c6ee4 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -8,13 +8,13 @@ from claude_code_log.cache import SessionCacheData from .models import ContentItem, TextContent, TranscriptEntry, UserTranscriptEntry -from .parser import ( +from .parser import is_system_message +from .user_parser import ( IDE_DIAGNOSTICS_PATTERN, IDE_OPENED_FILE_PATTERN, IDE_SELECTION_PATTERN, is_command_message, is_local_command_output, - is_system_message, ) diff --git a/test/test_ide_tags.py b/test/test_ide_tags.py index 2d451182..8059c47d 100644 --- a/test/test_ide_tags.py +++ b/test/test_ide_tags.py @@ -1,13 +1,16 @@ """Tests for IDE tag parsing and formatting in user messages. Split into: -- Parsing tests: test parse_ide_notifications() from parser.py +- Parsing tests: test parse_ide_notifications() from user_parser.py - Formatting tests: test format_ide_notification_content() from user_formatters.py - User message tests: test parse_user_message_content() and formatters - Assistant text tests: test format_assistant_text_content() """ -from claude_code_log.parser import parse_ide_notifications, parse_user_message_content +from claude_code_log.user_parser import ( + parse_ide_notifications, + parse_user_message_content, +) from claude_code_log.html.user_formatters import ( format_ide_notification_content, format_user_text_content, diff --git a/test/test_user_renderer.py b/test/test_user_renderer.py index f7dfd70d..602f4c8f 100644 --- a/test/test_user_renderer.py +++ b/test/test_user_renderer.py @@ -24,7 +24,7 @@ UserMemoryMessage, UserTextMessage, ) -from claude_code_log.parser import ( +from claude_code_log.user_parser import ( COMPACTED_SUMMARY_PREFIX, parse_compacted_summary, parse_user_memory, diff --git a/test/test_utils.py b/test/test_utils.py index 2b190774..418ef971 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -3,12 +3,14 @@ import pytest from claude_code_log.parser import ( + is_system_message, + is_warmup_only_session, +) +from claude_code_log.user_parser import ( is_bash_input, is_bash_output, is_command_message, is_local_command_output, - is_system_message, - is_warmup_only_session, ) from claude_code_log.utils import ( should_skip_message, From c7373fa95550051028b9495e92fd229bc800362e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 13:00:11 +0100 Subject: [PATCH 11/57] Move timing tracking from _render_messages to HtmlRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move timing initialization and _current_msg_uuid tracking from renderer.py to HtmlRenderer._flatten_preorder in html/renderer.py - Update _flatten_preorder to return operation timings along with flattened messages - Call report_timing_statistics in HtmlRenderer.generate() after content formatting - Update report_timing_statistics to handle empty message_timings (still reports operation timings for Markdown/Pygments) - Remove unused timing imports from renderer.py The timing tracking now happens where the actual formatting occurs, making it more accurate and keeping _render_messages focused on message structure creation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 41 +++++++++++++++++++++++++---- claude_code_log/renderer.py | 39 +++------------------------ claude_code_log/renderer_timings.py | 40 +++++++++++++++------------- 3 files changed, 60 insertions(+), 60 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 50d4fda4..73f13123 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -33,7 +33,12 @@ prepare_projects_index, title_for_projects_index, ) -from ..renderer_timings import log_timing +from ..renderer_timings import ( + DEBUG_TIMING, + log_timing, + report_timing_statistics, + set_timing_var, +) from ..utils import format_timestamp from .system_formatters import ( format_dedup_notice_content, @@ -141,21 +146,37 @@ def _format_tool_result_content(self, content: ToolResultMessage) -> str: def _flatten_preorder( self, roots: list[TemplateMessage] - ) -> list[Tuple[TemplateMessage, str, str]]: + ) -> Tuple[ + list[Tuple[TemplateMessage, str, str]], + list[Tuple[str, list[Tuple[float, 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, timestamp) tuples. + Also tracks timing statistics for Markdown and Pygments operations when + DEBUG_TIMING is enabled. + Args: roots: Root messages (typically session headers) with children populated Returns: - Flat list of (message, html_content, formatted_timestamp) tuples in pre-order + Tuple of: + - Flat list of (message, html_content, formatted_timestamp) tuples in pre-order + - Operation timing data for reporting: [("Markdown", timings), ("Pygments", timings)] """ flat: list[Tuple[TemplateMessage, str, str]] = [] + # Initialize timing tracking for expensive operations + markdown_timings: list[Tuple[float, str]] = [] + pygments_timings: list[Tuple[float, str]] = [] + set_timing_var("_markdown_timings", markdown_timings) + set_timing_var("_pygments_timings", pygments_timings) + def visit(msg: TemplateMessage) -> None: + # Update current message UUID for timing tracking + set_timing_var("_current_msg_uuid", msg.uuid) html = self.format_content(msg) formatted_ts = format_timestamp(msg.raw_timestamp) flat.append((msg, html, formatted_ts)) @@ -165,7 +186,13 @@ def visit(msg: TemplateMessage) -> None: for root in roots: visit(root) - return flat + # Return timing data for reporting + operation_timings: list[Tuple[str, list[Tuple[float, str]]]] = [ + ("Markdown", markdown_timings), + ("Pygments", pygments_timings), + ] + + return flat, operation_timings def generate( self, @@ -186,7 +213,11 @@ def generate( # 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) + template_messages, operation_timings = self._flatten_preorder(root_messages) + + # Report timing statistics for Markdown/Pygments operations + if DEBUG_TIMING: + report_timing_statistics([], operation_timings) # Render template with log_timing("Template environment setup", t_start): diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index d4e4478b..4c1f4559 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -68,9 +68,6 @@ create_session_preview, ) from .renderer_timings import ( - DEBUG_TIMING, - report_timing_statistics, - set_timing_var, log_timing, ) @@ -1803,27 +1800,8 @@ def _render_messages( # Process messages into template-friendly format template_messages: list[TemplateMessage] = [] - # Per-message timing tracking - message_timings: list[ - tuple[float, str, int, str] - ] = [] # (duration, message_type, index, uuid) - - # Track expensive operations - markdown_timings: list[tuple[float, str]] = [] # (duration, context_uuid) - pygments_timings: list[tuple[float, str]] = [] # (duration, context_uuid) - - # Initialize timing tracking - set_timing_var("_markdown_timings", markdown_timings) - set_timing_var("_pygments_timings", pygments_timings) - set_timing_var("_current_msg_uuid", "") - - for msg_idx, message in enumerate(messages): - msg_start_time = time.time() if DEBUG_TIMING else 0.0 + for message in messages: message_type = message.type - msg_uuid = getattr(message, "uuid", f"no-uuid-{msg_idx}") - - # Update current message UUID for timing tracking - set_timing_var("_current_msg_uuid", msg_uuid) # Handle system messages separately (already filtered in pass 1) if isinstance(message, SystemTranscriptEntry): @@ -2077,10 +2055,11 @@ def _render_messages( # Generate unique UUID for this tool message # Use tool_use_id if available, otherwise fall back to msg UUID + index + message_uuid = getattr(message, "uuid", "no-uuid") tool_uuid = ( tool_result.tool_use_id if tool_result.tool_use_id - else f"{msg_uuid}-tool-{len(template_messages)}" + else f"{message_uuid}-tool-{len(template_messages)}" ) # Thinking content uses markdown @@ -2110,18 +2089,6 @@ def _render_messages( template_messages.append(tool_template_message) - # Track message timing - if DEBUG_TIMING: - msg_duration = time.time() - msg_start_time - message_timings.append((msg_duration, message_type, msg_idx, msg_uuid)) - - # Report loop statistics - if DEBUG_TIMING: - report_timing_statistics( - message_timings, - [("Markdown", markdown_timings), ("Pygments", pygments_timings)], - ) - return template_messages diff --git a/claude_code_log/renderer_timings.py b/claude_code_log/renderer_timings.py index 96aff4b7..d4a6e994 100644 --- a/claude_code_log/renderer_timings.py +++ b/claude_code_log/renderer_timings.py @@ -117,30 +117,32 @@ def report_timing_statistics( """Report timing statistics for message rendering. Args: - message_timings: List of (duration, message_type, index, uuid) tuples + message_timings: List of (duration, message_type, index, uuid) tuples. + Can be empty if only operation timings are being reported. operation_timings: List of (name, timings) tuples where timings is a list of (duration, uuid) e.g., [("Markdown", markdown_timings), ("Pygments", pygments_timings)] """ - if not message_timings: - return - - # Sort by duration descending - sorted_timings = sorted(message_timings, key=lambda x: x[0], reverse=True) - - # Calculate statistics - total_msg_time = sum(t[0] for t in message_timings) - avg_time = total_msg_time / len(message_timings) - - # Report slowest messages - print("\n[TIMING] Loop statistics:", flush=True) - print(f"[TIMING] Total messages: {len(message_timings)}", flush=True) - print(f"[TIMING] Average time per message: {avg_time * 1000:.1f}ms", flush=True) - print("[TIMING] Slowest 10 messages:", flush=True) - for duration, msg_type, idx, uuid in sorted_timings[:10]: + # Report message loop statistics if available + if message_timings: + # Sort by duration descending + sorted_timings = sorted(message_timings, key=lambda x: x[0], reverse=True) + + # Calculate statistics + total_msg_time = sum(t[0] for t in message_timings) + avg_time = total_msg_time / len(message_timings) + + # Report slowest messages + print("\n[TIMING] Loop statistics:", flush=True) + print(f"[TIMING] Total messages: {len(message_timings)}", flush=True) print( - f"[TIMING] Message {uuid} (#{idx}, {msg_type}): {duration * 1000:.1f}ms", - flush=True, + f"[TIMING] Average time per message: {avg_time * 1000:.1f}ms", flush=True ) + print("[TIMING] Slowest 10 messages:", flush=True) + for duration, msg_type, idx, uuid in sorted_timings[:10]: + print( + f"[TIMING] Message {uuid} (#{idx}, {msg_type}): {duration * 1000:.1f}ms", + flush=True, + ) # Report operation-specific statistics for operation_name, timings in operation_timings: From 940c2e6c556f4e2439296435f6bb0447d05e30cc Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 13:48:26 +0100 Subject: [PATCH 12/57] Extract tool parsing into dedicated tool_parser.py module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all tool-related parsing from parser.py and renderer.py into a new tool_parser.py module: - TOOL_INPUT_MODELS and TOOL_LENIENT_PARSERS mappings - All _parse_*_lenient helper functions - parse_tool_input() for typed tool input parsing - ToolItemResult dataclass for tool processing results - parse_tool_use_item() (renamed from _process_tool_use_item) - parse_tool_result_item() (renamed from _process_tool_result_item) This consolidates tool-related code following the pattern established with system_parser.py, user_parser.py, and assistant_parser.py. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/parser.py | 207 ------------------ claude_code_log/renderer.py | 159 +------------- claude_code_log/tool_parser.py | 388 +++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+), 360 deletions(-) create mode 100644 claude_code_log/tool_parser.py diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index c50fe614..3ace4338 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -4,8 +4,6 @@ from typing import Any, Callable, Optional, cast from datetime import datetime -from pydantic import BaseModel - from .models import ( # Common metadata MessageMeta, @@ -17,23 +15,6 @@ ToolUseContent, ToolResultContent, ImageContent, - # 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, @@ -165,194 +146,6 @@ def as_assistant_entry(entry: TranscriptEntry) -> AssistantTranscriptEntry | Non return None -# ============================================================================= -# 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)), - ) - ) - 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]) -> Optional[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 parsing succeeds, None otherwise. - When None is returned, the caller should use ToolUseContent itself - as the fallback (it's part of the ToolInput union). - """ - 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 None - return None - - # ============================================================================= # Usage Info Normalization # ============================================================================= diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 4c1f4559..6b16a93d 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -21,9 +21,7 @@ ContentItem, TextContent, ToolResultContent, - ToolResultMessage, ToolUseContent, - ToolUseMessage, ThinkingContent, ThinkingMessage, # Structured content types @@ -71,11 +69,11 @@ log_timing, ) -from .html import ( - escape_html, - format_tool_use_title, +from .tool_parser import ( + ToolItemResult, + parse_tool_use_item, + parse_tool_result_item, ) -from .parser import parse_tool_input # -- Content Formatters ------------------------------------------------------- @@ -746,151 +744,6 @@ def chunk_message_content(content: list[ContentItem]) -> list[ContentChunk]: return chunks -@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 - - # Parse tool input once, use for both title and message content - parsed = parse_tool_input(tool_use.name, tool_use.input) - - # Title is computed here but content formatting happens in HtmlRenderer - tool_message_title = format_tool_use_title(tool_use.name, parsed) - 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 - - # Create ToolUseMessage wrapper with parsed input for specialized formatting - # Use ToolUseContent as fallback when no specialized parser exists - tool_use_message = ToolUseMessage( - input=parsed if parsed is not None else tool_use, - tool_use_id=tool_use.id, - tool_name=tool_use.name, - ) - - return ToolItemResult( - message_type="tool_use", - message_title=tool_message_title, - content=tool_use_message, - 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: - 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 - # Pass the whole ToolResultContent as output (generic fallback) - # TODO: Parse into specialized output types (ReadOutput, EditOutput) when appropriate - content_model = ToolResultMessage( - tool_use_id=tool_result.tool_use_id, - output=tool_result, # ToolResultContent as ToolOutput - 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, - ) - - # -- Message Pairing ---------------------------------------------------------- @@ -2025,12 +1878,12 @@ def _render_messages( # Dispatch to appropriate handler based on item type tool_result: Optional[ToolItemResult] = None if isinstance(tool_item, ToolUseContent) or item_type == "tool_use": - tool_result = _process_tool_use_item(tool_item, tool_use_context) + tool_result = parse_tool_use_item(tool_item, tool_use_context) elif ( isinstance(tool_item, ToolResultContent) or item_type == "tool_result" ): - tool_result = _process_tool_result_item(tool_item, tool_use_context) + tool_result = parse_tool_result_item(tool_item, tool_use_context) elif isinstance(tool_item, ThinkingContent) or item_type == "thinking": content = parse_thinking_item(tool_item) tool_result = ToolItemResult( diff --git a/claude_code_log/tool_parser.py b/claude_code_log/tool_parser.py new file mode 100644 index 00000000..0c38262e --- /dev/null +++ b/claude_code_log/tool_parser.py @@ -0,0 +1,388 @@ +"""Parser for tool use and tool result content. + +This module handles parsing of tool-related content into MessageContent subclasses: +- ToolUseMessage: Tool invocations with typed inputs (BashInput, ReadInput, etc.) +- ToolResultMessage: Tool results with output and context + +Also provides parsing of tool inputs into typed models: +- parse_tool_input(): Parse raw tool input dict into typed model +- parse_tool_use_item(): Process ToolUseContent into ToolUseMessage +- parse_tool_result_item(): Process ToolResultContent into ToolResultMessage +""" + +from dataclasses import dataclass +from typing import Any, Optional, cast + +from pydantic import BaseModel + +from .models import ( + # Tool input models + AskUserQuestionInput, + AskUserQuestionItem, + AskUserQuestionOption, + BashInput, + ContentItem, + EditInput, + EditItem, + ExitPlanModeInput, + GlobInput, + GrepInput, + MessageContent, + MultiEditInput, + ReadInput, + TaskInput, + TodoWriteInput, + TodoWriteItem, + ToolInput, + ToolResultContent, + ToolResultMessage, + ToolUseContent, + ToolUseMessage, + WriteInput, +) +from .html import escape_html, format_tool_use_title + + +# ============================================================================= +# Tool Input Models Mapping +# ============================================================================= + +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)), + ) + ) + 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, +} + + +# ============================================================================= +# Tool Input Parsing +# ============================================================================= + + +def parse_tool_input(tool_name: str, input_data: dict[str, Any]) -> Optional[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 parsing succeeds, None otherwise. + When None is returned, the caller should use ToolUseContent itself + as the fallback (it's part of the ToolInput union). + """ + 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 None + return None + + +# ============================================================================= +# Tool Item Processing +# ============================================================================= + + +@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 parse_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 + + # Parse tool input once, use for both title and message content + parsed = parse_tool_input(tool_use.name, tool_use.input) + + # Title is computed here but content formatting happens in HtmlRenderer + tool_message_title = format_tool_use_title(tool_use.name, parsed) + 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 + + # Create ToolUseMessage wrapper with parsed input for specialized formatting + # Use ToolUseContent as fallback when no specialized parser exists + tool_use_message = ToolUseMessage( + input=parsed if parsed is not None else tool_use, + tool_use_id=tool_use.id, + tool_name=tool_use.name, + ) + + return ToolItemResult( + message_type="tool_use", + message_title=tool_message_title, + content=tool_use_message, + tool_use_id=item_tool_use_id, + title_hint=tool_title_hint, + ) + + +def parse_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: + 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 + # Pass the whole ToolResultContent as output (generic fallback) + # TODO: Parse into specialized output types (ReadOutput, EditOutput) when appropriate + content_model = ToolResultMessage( + tool_use_id=tool_result.tool_use_id, + output=tool_result, # ToolResultContent as ToolOutput + 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, + ) From 6b62bfb79272be17db90ce9664401577a72c14b3 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 16:44:34 +0100 Subject: [PATCH 13/57] Extract transcript parsing into dedicated transcript_parser.py module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all transcript-related parsing from parser.py into a new transcript_parser.py module: - Type guards: as_user_entry, as_assistant_entry - Usage info normalization: normalize_usage_info - Content item parsing: parse_user_content_item, parse_assistant_content_item, parse_content_item, parse_message_content - Transcript entry parsing: parse_transcript_entry parser.py now re-exports these functions for backward compatibility, keeping extract_text_content, parse_meta, parse_timestamp, is_system_message, and is_warmup_only_session as local utilities. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/parser.py | 309 +++------------------------ claude_code_log/transcript_parser.py | 294 +++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 276 deletions(-) create mode 100644 claude_code_log/transcript_parser.py diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 3ace4338..5bb6d445 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -1,31 +1,51 @@ #!/usr/bin/env python3 """Parse and extract data from Claude transcript JSONL files.""" -from typing import Any, Callable, Optional, cast from datetime import datetime +from typing import Optional from .models import ( # Common metadata - MessageMeta, BaseTranscriptEntry, + MessageMeta, # Content types ContentItem, - TextContent, ThinkingContent, - ToolUseContent, - ToolResultContent, - ImageContent, - # Usage and transcript entry types - UsageInfo, - MessageType, + # Transcript entry types TranscriptEntry, UserTranscriptEntry, - AssistantTranscriptEntry, - SummaryTranscriptEntry, - SystemTranscriptEntry, - QueueOperationTranscriptEntry, ) +# Re-export transcript parsing functions for backward compatibility +from .transcript_parser import ( + as_assistant_entry, + as_user_entry, + normalize_usage_info, + parse_assistant_content_item, + parse_content_item, + parse_message_content, + parse_transcript_entry, + parse_user_content_item, +) + +__all__ = [ + # Local functions + "parse_meta", + "extract_text_content", + "parse_timestamp", + "is_system_message", + "is_warmup_only_session", + # Re-exported from transcript_parser + "as_user_entry", + "as_assistant_entry", + "normalize_usage_info", + "parse_user_content_item", + "parse_assistant_content_item", + "parse_content_item", + "parse_message_content", + "parse_transcript_entry", +] + def parse_meta(transcript: BaseTranscriptEntry) -> MessageMeta: """Extract common metadata from a transcript entry. @@ -125,266 +145,3 @@ def is_warmup_only_session(messages: list[TranscriptEntry], session_id: str) -> # All user messages must be exactly "Warmup" return all(msg == "Warmup" for msg in user_messages_in_session) - - -# ============================================================================= -# Type Guards for TranscriptEntry -# ============================================================================= - - -def as_user_entry(entry: TranscriptEntry) -> UserTranscriptEntry | None: - """Return entry as UserTranscriptEntry if it is one, else None.""" - if entry.type == MessageType.USER: - return cast(UserTranscriptEntry, entry) - return None - - -def as_assistant_entry(entry: TranscriptEntry) -> AssistantTranscriptEntry | None: - """Return entry as AssistantTranscriptEntry if it is one, else None.""" - if entry.type == MessageType.ASSISTANT: - return cast(AssistantTranscriptEntry, entry) - return None - - -# ============================================================================= -# Usage Info Normalization -# ============================================================================= - - -def normalize_usage_info(usage_data: Any) -> Optional[UsageInfo]: - """Normalize usage data from various formats to UsageInfo.""" - 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 a dict, validate and convert - if isinstance(usage_data, dict): - return UsageInfo.model_validate(usage_data) - - # Handle object-like access (e.g., from SDK types) - if hasattr(usage_data, "input_tokens"): - server_tool_use = getattr(usage_data, "server_tool_use", None) - if server_tool_use is not None and hasattr(server_tool_use, "model_dump"): - server_tool_use = server_tool_use.model_dump() - return UsageInfo( - input_tokens=getattr(usage_data, "input_tokens", None), - output_tokens=getattr(usage_data, "output_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 - ), - service_tier=getattr(usage_data, "service_tier", None), - server_tool_use=server_tool_use, - ) - - 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. - - Common to both user and assistant messages. - """ - 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. - - 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": - return ToolUseContent.model_validate(item_data) - elif content_type == "thinking": - 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", "") - - if content_type == "tool_result": - return ToolResultContent.model_validate(item_data) - elif content_type == "image": - return ImageContent.model_validate(item_data) - elif content_type == "tool_use": - return ToolUseContent.model_validate(item_data) - elif content_type == "thinking": - return ThinkingContent.model_validate(item_data) - 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, -) -> list[ContentItem]: - """Parse message content, normalizing to a list of ContentItems. - - Always returns a list for consistent downstream handling. String content - is wrapped in a TextContent item. - - 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 [TextContent(type="text", text=content_data)] - elif isinstance(content_data, list): - content_list = cast(list[Any], content_data) - result: list[ContentItem] = [] - for item in content_list: - if isinstance(item, dict): - result.append(item_parser(cast(dict[str, Any], item))) - else: - # Non-dict items (e.g., raw strings) become TextContent - result.append(TextContent(type="text", text=str(item))) - return result - else: - return [TextContent(type="text", text=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": - data_copy = data.copy() - - # Parse assistant message content - 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/transcript_parser.py b/claude_code_log/transcript_parser.py new file mode 100644 index 00000000..7a85c01a --- /dev/null +++ b/claude_code_log/transcript_parser.py @@ -0,0 +1,294 @@ +"""Parser for transcript entries and content items. + +This module handles parsing of JSONL transcript data into typed models: +- TranscriptEntry subclasses (User, Assistant, Summary, System, QueueOperation) +- ContentItem subclasses (Text, ToolUse, ToolResult, Thinking, Image) + +Also provides: +- Type guards for TranscriptEntry discrimination +- Usage info normalization for Anthropic SDK compatibility +""" + +from typing import Any, Callable, Optional, cast + +from .models import ( + # Content types + ContentItem, + ImageContent, + TextContent, + ThinkingContent, + ToolResultContent, + ToolUseContent, + # Transcript entry types + AssistantTranscriptEntry, + MessageType, + QueueOperationTranscriptEntry, + SummaryTranscriptEntry, + SystemTranscriptEntry, + TranscriptEntry, + UsageInfo, + UserTranscriptEntry, +) + + +# ============================================================================= +# Type Guards for TranscriptEntry +# ============================================================================= + + +def as_user_entry(entry: TranscriptEntry) -> UserTranscriptEntry | None: + """Return entry as UserTranscriptEntry if it is one, else None.""" + if entry.type == MessageType.USER: + return cast(UserTranscriptEntry, entry) + return None + + +def as_assistant_entry(entry: TranscriptEntry) -> AssistantTranscriptEntry | None: + """Return entry as AssistantTranscriptEntry if it is one, else None.""" + if entry.type == MessageType.ASSISTANT: + return cast(AssistantTranscriptEntry, entry) + return None + + +# ============================================================================= +# Usage Info Normalization +# ============================================================================= + + +def normalize_usage_info(usage_data: Any) -> Optional[UsageInfo]: + """Normalize usage data from various formats to UsageInfo.""" + 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 a dict, validate and convert + if isinstance(usage_data, dict): + return UsageInfo.model_validate(usage_data) + + # Handle object-like access (e.g., from SDK types) + if hasattr(usage_data, "input_tokens"): + server_tool_use = getattr(usage_data, "server_tool_use", None) + if server_tool_use is not None and hasattr(server_tool_use, "model_dump"): + server_tool_use = server_tool_use.model_dump() + return UsageInfo( + input_tokens=getattr(usage_data, "input_tokens", None), + output_tokens=getattr(usage_data, "output_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 + ), + service_tier=getattr(usage_data, "service_tier", None), + server_tool_use=server_tool_use, + ) + + 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. + + Common to both user and assistant messages. + """ + 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. + + 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": + return ToolUseContent.model_validate(item_data) + elif content_type == "thinking": + 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", "") + + if content_type == "tool_result": + return ToolResultContent.model_validate(item_data) + elif content_type == "image": + return ImageContent.model_validate(item_data) + elif content_type == "tool_use": + return ToolUseContent.model_validate(item_data) + elif content_type == "thinking": + return ThinkingContent.model_validate(item_data) + 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, +) -> list[ContentItem]: + """Parse message content, normalizing to a list of ContentItems. + + Always returns a list for consistent downstream handling. String content + is wrapped in a TextContent item. + + 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 [TextContent(type="text", text=content_data)] + elif isinstance(content_data, list): + content_list = cast(list[Any], content_data) + result: list[ContentItem] = [] + for item in content_list: + if isinstance(item, dict): + result.append(item_parser(cast(dict[str, Any], item))) + else: + # Non-dict items (e.g., raw strings) become TextContent + result.append(TextContent(type="text", text=str(item))) + return result + else: + return [TextContent(type="text", text=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": + data_copy = data.copy() + + # Parse assistant message content + 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}") From 4c668bd188d9ab74c66e60f8e4f6167deb1e4079 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 17:08:05 +0100 Subject: [PATCH 14/57] Remove parser.py re-exports, update imports throughout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove backward compatibility re-exports from parser.py - Move is_system_message to system_parser.py - Update all imports to use transcript_parser.py directly: - as_user_entry, as_assistant_entry - parse_transcript_entry, parse_content_item - parse_message_content, normalize_usage_info - Remove is_warmup_only_session (unused except in tests) - Update test imports to use correct modules parser.py now only contains: - parse_meta: Extract metadata from transcript entries - extract_text_content: Extract text from content items - parse_timestamp: Parse ISO timestamps 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/cache.py | 4 +- claude_code_log/converter.py | 3 +- claude_code_log/parser.py | 91 ++----------------- claude_code_log/renderer.py | 4 +- claude_code_log/system_parser.py | 24 +++++ claude_code_log/utils.py | 2 +- test/test_context_command.py | 2 +- test/test_date_filtering.py | 2 +- test/test_hook_summary.py | 2 +- test/test_message_filtering.py | 2 +- test/test_toggle_functionality.py | 2 +- test/test_utils.py | 140 +----------------------------- 12 files changed, 46 insertions(+), 232 deletions(-) diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index ad443726..0b5f7a63 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -172,7 +172,7 @@ def load_cached_entries(self, jsonl_path: Path) -> Optional[list[TranscriptEntry entries_data.extend(cast(list[dict[str, Any]], timestamp_entries)) # Deserialize back to TranscriptEntry objects - from .parser import parse_transcript_entry + from .transcript_parser import parse_transcript_entry entries = [ parse_transcript_entry(entry_dict) for entry_dict in entries_data @@ -257,7 +257,7 @@ def load_cached_entries_filtered( ) # Deserialize filtered entries - from .parser import parse_transcript_entry + from .transcript_parser import parse_transcript_entry entries = [ parse_transcript_entry(entry_dict) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 12a2fa83..6164ae2a 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -21,7 +21,8 @@ get_warmup_session_ids, ) from .cache import CacheManager, SessionCacheData, get_library_version -from .parser import parse_timestamp, parse_transcript_entry +from .parser import parse_timestamp +from .transcript_parser import parse_transcript_entry from .models import ( TranscriptEntry, AssistantTranscriptEntry, diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 5bb6d445..e17f74d7 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -1,5 +1,13 @@ #!/usr/bin/env python3 -"""Parse and extract data from Claude transcript JSONL files.""" +"""Parse and extract data from Claude transcript JSONL files. + +This module provides utility functions for parsing transcript data: +- parse_meta: Extract common metadata from transcript entries +- extract_text_content: Extract text from content items +- parse_timestamp: Parse ISO timestamps + +For transcript entry and content item parsing, see transcript_parser.py. +""" from datetime import datetime from typing import Optional @@ -11,41 +19,8 @@ # Content types ContentItem, ThinkingContent, - # Transcript entry types - TranscriptEntry, - UserTranscriptEntry, -) - -# Re-export transcript parsing functions for backward compatibility -from .transcript_parser import ( - as_assistant_entry, - as_user_entry, - normalize_usage_info, - parse_assistant_content_item, - parse_content_item, - parse_message_content, - parse_transcript_entry, - parse_user_content_item, ) -__all__ = [ - # Local functions - "parse_meta", - "extract_text_content", - "parse_timestamp", - "is_system_message", - "is_warmup_only_session", - # Re-exported from transcript_parser - "as_user_entry", - "as_assistant_entry", - "normalize_usage_info", - "parse_user_content_item", - "parse_assistant_content_item", - "parse_content_item", - "parse_message_content", - "parse_transcript_entry", -] - def parse_meta(transcript: BaseTranscriptEntry) -> MessageMeta: """Extract common metadata from a transcript entry. @@ -97,51 +72,3 @@ def parse_timestamp(timestamp_str: str) -> Optional[datetime]: return datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) except (ValueError, AttributeError): return None - - -# ============================================================================= -# 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_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) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 6b16a93d..35adb8eb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -37,10 +37,10 @@ UserSteeringMessage, UserTextMessage, ) -from .parser import ( +from .parser import extract_text_content +from .transcript_parser import ( as_assistant_entry, as_user_entry, - extract_text_content, ) from .user_parser import ( is_bash_input, diff --git a/claude_code_log/system_parser.py b/claude_code_log/system_parser.py index f1881838..82ba3576 100644 --- a/claude_code_log/system_parser.py +++ b/claude_code_log/system_parser.py @@ -3,6 +3,9 @@ This module handles parsing of SystemTranscriptEntry into MessageContent subclasses: - SystemMessage: Regular system messages with level (info, warning, error) - HookSummaryMessage: Hook execution summaries + +Also provides: +- is_system_message: Check if text content is a system message to filter """ from typing import Optional, Union @@ -16,6 +19,27 @@ from .parser import parse_meta +# ============================================================================= +# System Message 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) + + +# ============================================================================= +# System Transcript Parsing +# ============================================================================= + + def parse_system_transcript( transcript: SystemTranscriptEntry, ) -> Optional[Union[SystemMessage, HookSummaryMessage]]: diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index b89c6ee4..d0ba7930 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -8,7 +8,7 @@ from claude_code_log.cache import SessionCacheData from .models import ContentItem, TextContent, TranscriptEntry, UserTranscriptEntry -from .parser import is_system_message +from .system_parser import is_system_message from .user_parser import ( IDE_DIAGNOSTICS_PATTERN, IDE_OPENED_FILE_PATTERN, diff --git a/test/test_context_command.py b/test/test_context_command.py index 9b893439..95c025e9 100644 --- a/test/test_context_command.py +++ b/test/test_context_command.py @@ -1,7 +1,7 @@ """Tests for /context command output rendering.""" from claude_code_log.html.renderer import generate_html -from claude_code_log.parser import parse_transcript_entry +from claude_code_log.transcript_parser import parse_transcript_entry def test_context_command_rendering(): diff --git a/test/test_date_filtering.py b/test/test_date_filtering.py index 722bd41d..794554f4 100644 --- a/test/test_date_filtering.py +++ b/test/test_date_filtering.py @@ -7,7 +7,7 @@ from pathlib import Path from claude_code_log.converter import convert_jsonl_to_html from claude_code_log.converter import filter_messages_by_date -from claude_code_log.parser import parse_transcript_entry +from claude_code_log.transcript_parser import parse_transcript_entry def create_test_message(timestamp_str: str, text: str) -> dict: diff --git a/test/test_hook_summary.py b/test/test_hook_summary.py index a40b265c..71bce7b8 100644 --- a/test/test_hook_summary.py +++ b/test/test_hook_summary.py @@ -1,7 +1,7 @@ """Tests for hook summary (stop_hook_summary) parsing and rendering.""" from claude_code_log.models import SystemTranscriptEntry -from claude_code_log.parser import parse_transcript_entry +from claude_code_log.transcript_parser import parse_transcript_entry from claude_code_log.html.renderer import generate_html diff --git a/test/test_message_filtering.py b/test/test_message_filtering.py index e937fa38..03869980 100644 --- a/test/test_message_filtering.py +++ b/test/test_message_filtering.py @@ -6,7 +6,7 @@ from pathlib import Path from claude_code_log.converter import load_transcript from claude_code_log.html.renderer import generate_html -from claude_code_log.parser import is_system_message +from claude_code_log.system_parser import is_system_message def test_caveat_message_filtering(): diff --git a/test/test_toggle_functionality.py b/test/test_toggle_functionality.py index e91ee430..eca944d1 100644 --- a/test/test_toggle_functionality.py +++ b/test/test_toggle_functionality.py @@ -6,7 +6,7 @@ AssistantMessageModel, UsageInfo, ) -from claude_code_log.parser import parse_content_item +from claude_code_log.transcript_parser import parse_content_item from claude_code_log.html.renderer import generate_html diff --git a/test/test_utils.py b/test/test_utils.py index 418ef971..3dc099ed 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -2,10 +2,7 @@ """Test cases for the utils module functions.""" import pytest -from claude_code_log.parser import ( - is_system_message, - is_warmup_only_session, -) +from claude_code_log.system_parser import is_system_message from claude_code_log.user_parser import ( is_bash_input, is_bash_output, @@ -26,8 +23,6 @@ ToolUseContent, UserTranscriptEntry, UserMessageModel, - AssistantTranscriptEntry, - AssistantMessageModel, ) @@ -608,139 +603,6 @@ def test_create_session_preview_multiple_ide_tags(self): assert "Please review this code for bugs" in preview -class TestWarmupOnlySessionDetection: - """Test detection of warmup-only sessions.""" - - def _create_user_entry( - self, session_id: str, content: str, uuid: str, timestamp: str - ) -> UserTranscriptEntry: - """Helper to create a UserTranscriptEntry with all required fields.""" - return UserTranscriptEntry( - type="user", - sessionId=session_id, - parentUuid=None, - isSidechain=False, - userType="external", - cwd="/test", - version="1.0.0", - message=UserMessageModel( - role="user", content=[TextContent(type="text", text=content)] - ), - uuid=uuid, - timestamp=timestamp, - ) - - def _create_assistant_entry( - self, - session_id: str, - content: str, - uuid: str, - timestamp: str, - parent_uuid: str, - ) -> AssistantTranscriptEntry: - """Helper to create an AssistantTranscriptEntry with all required fields.""" - return AssistantTranscriptEntry( - type="assistant", - sessionId=session_id, - parentUuid=parent_uuid, - isSidechain=False, - userType="external", - cwd="/test", - version="1.0.0", - message=AssistantMessageModel( - id="msg-id", - type="message", - role="assistant", - model="claude-3-5-sonnet", - content=[TextContent(type="text", text=content)], - ), - uuid=uuid, - timestamp=timestamp, - ) - - def test_session_with_only_warmup_messages(self): - """Test that a session with only warmup messages is detected.""" - session_id = "test-session-1" - messages = [ - self._create_user_entry( - session_id, "Warmup", "msg-1", "2025-01-01T10:00:00Z" - ), - self._create_assistant_entry( - session_id, - "I'm ready to help!", - "msg-2", - "2025-01-01T10:00:01Z", - "msg-1", - ), - ] - - assert is_warmup_only_session(messages, session_id) is True - - def test_session_with_real_messages(self): - """Test that a session with real messages is not detected as warmup-only.""" - session_id = "test-session-2" - messages = [ - self._create_user_entry( - session_id, "Hello, can you help me?", "msg-1", "2025-01-01T10:00:00Z" - ), - self._create_assistant_entry( - session_id, "Sure!", "msg-2", "2025-01-01T10:00:01Z", "msg-1" - ), - ] - - assert is_warmup_only_session(messages, session_id) is False - - def test_session_with_warmup_and_real_messages(self): - """Test that a session with both warmup and real messages is not warmup-only.""" - session_id = "test-session-3" - messages = [ - self._create_user_entry( - session_id, "Warmup", "msg-1", "2025-01-01T10:00:00Z" - ), - self._create_assistant_entry( - session_id, "Ready!", "msg-2", "2025-01-01T10:00:01Z", "msg-1" - ), - self._create_user_entry( - session_id, - "Now help me debug this code", - "msg-3", - "2025-01-01T10:00:02Z", - ), - ] - - assert is_warmup_only_session(messages, session_id) is False - - def test_session_with_multiple_warmup_messages(self): - """Test session with multiple warmup messages.""" - session_id = "test-session-4" - messages = [ - self._create_user_entry( - session_id, " Warmup ", "msg-1", "2025-01-01T10:00:00Z" - ), - self._create_user_entry( - session_id, "Warmup", "msg-2", "2025-01-01T10:00:01Z" - ), - ] - - assert is_warmup_only_session(messages, session_id) is True - - def test_nonexistent_session(self): - """Test checking a session ID that doesn't exist.""" - messages = [ - self._create_user_entry( - "different-session", "Hello", "msg-1", "2025-01-01T10:00:00Z" - ), - ] - - # Should return False (no user messages may mean system messages exist) - assert is_warmup_only_session(messages, "nonexistent-session") is False - - def test_empty_messages_list(self): - """Test with empty messages list.""" - # Should return False (no user messages may mean system messages exist) - assert is_warmup_only_session([], "any-session") is False - - class TestGetWarmupSessionIds: """Test bulk warmup session ID detection.""" From 846a0008aac7af87c3e7789d744b271c128a325c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 18:00:10 +0100 Subject: [PATCH 15/57] Refactor transcript_parser.py to factories/transcript_factory.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename module to factories/transcript_factory.py with registry-based dispatch - Rename parse_* functions to create_* (create_transcript_entry, create_content_item) - Add CONTENT_ITEM_CREATORS registry mapping type strings to model classes - Add type_filter parameter (None = allow all) replacing ALL_CONTENT_TYPES - Export USER_CONTENT_TYPES and ASSISTANT_CONTENT_TYPES constants - Update all imports throughout codebase and tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/cache.py | 8 +- claude_code_log/converter.py | 4 +- claude_code_log/factories/__init__.py | 33 ++ .../factories/transcript_factory.py | 265 ++++++++++++++++ claude_code_log/renderer.py | 2 +- claude_code_log/transcript_parser.py | 294 ------------------ test/test_context_command.py | 8 +- test/test_date_filtering.py | 8 +- test/test_hook_summary.py | 18 +- test/test_toggle_functionality.py | 6 +- 10 files changed, 325 insertions(+), 321 deletions(-) create mode 100644 claude_code_log/factories/__init__.py create mode 100644 claude_code_log/factories/transcript_factory.py delete mode 100644 claude_code_log/transcript_parser.py diff --git a/claude_code_log/cache.py b/claude_code_log/cache.py index 0b5f7a63..3f5d43b7 100644 --- a/claude_code_log/cache.py +++ b/claude_code_log/cache.py @@ -172,10 +172,10 @@ def load_cached_entries(self, jsonl_path: Path) -> Optional[list[TranscriptEntry entries_data.extend(cast(list[dict[str, Any]], timestamp_entries)) # Deserialize back to TranscriptEntry objects - from .transcript_parser import parse_transcript_entry + from .factories import create_transcript_entry entries = [ - parse_transcript_entry(entry_dict) for entry_dict in entries_data + create_transcript_entry(entry_dict) for entry_dict in entries_data ] return entries except Exception as e: @@ -257,10 +257,10 @@ def load_cached_entries_filtered( ) # Deserialize filtered entries - from .transcript_parser import parse_transcript_entry + from .factories import create_transcript_entry entries = [ - parse_transcript_entry(entry_dict) + create_transcript_entry(entry_dict) for entry_dict in filtered_entries_data ] return entries diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 6164ae2a..8407f6b3 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -22,7 +22,7 @@ ) from .cache import CacheManager, SessionCacheData, get_library_version from .parser import parse_timestamp -from .transcript_parser import parse_transcript_entry +from .factories import create_transcript_entry from .models import ( TranscriptEntry, AssistantTranscriptEntry, @@ -184,7 +184,7 @@ def load_transcript( "queue-operation", ]: # Parse using Pydantic models - entry = parse_transcript_entry(entry_dict) + entry = create_transcript_entry(entry_dict) messages.append(entry) elif ( entry_type diff --git a/claude_code_log/factories/__init__.py b/claude_code_log/factories/__init__.py new file mode 100644 index 00000000..188bb4e0 --- /dev/null +++ b/claude_code_log/factories/__init__.py @@ -0,0 +1,33 @@ +"""Factory modules for creating typed objects from raw data.""" + +from .transcript_factory import ( + # Content type constants + ASSISTANT_CONTENT_TYPES, + USER_CONTENT_TYPES, + # Conditional casts + as_assistant_entry, + as_user_entry, + # Usage normalization + normalize_usage_info, + # Content item creation + create_content_item, + create_message_content, + # Transcript entry creation + create_transcript_entry, +) + +__all__ = [ + # Content type constants + "USER_CONTENT_TYPES", + "ASSISTANT_CONTENT_TYPES", + # Conditional casts + "as_user_entry", + "as_assistant_entry", + # Usage normalization + "normalize_usage_info", + # Content item creation + "create_content_item", + "create_message_content", + # Transcript entry creation + "create_transcript_entry", +] diff --git a/claude_code_log/factories/transcript_factory.py b/claude_code_log/factories/transcript_factory.py new file mode 100644 index 00000000..c70821e1 --- /dev/null +++ b/claude_code_log/factories/transcript_factory.py @@ -0,0 +1,265 @@ +"""Factory for creating TranscriptEntry and ContentItem instances from raw data. + +This module creates typed model instances from JSONL transcript data: +- TranscriptEntry subclasses (User, Assistant, Summary, System, QueueOperation) +- ContentItem subclasses (Text, ToolUse, ToolResult, Thinking, Image) + +Also provides: +- Conditional casts for TranscriptEntry discrimination +- Usage info normalization for Anthropic SDK compatibility +""" + +from typing import Any, Callable, Optional, Sequence, cast + +from pydantic import BaseModel + +from ..models import ( + # Content types + ContentItem, + ImageContent, + TextContent, + ThinkingContent, + ToolResultContent, + ToolUseContent, + # Transcript entry types + AssistantTranscriptEntry, + MessageType, + QueueOperationTranscriptEntry, + SummaryTranscriptEntry, + SystemTranscriptEntry, + TranscriptEntry, + UsageInfo, + UserTranscriptEntry, +) + + +# ============================================================================= +# Content Item Registry +# ============================================================================= + +# Maps content type strings to their model classes +CONTENT_ITEM_CREATORS: dict[str, type[BaseModel]] = { + "text": TextContent, + "tool_result": ToolResultContent, + "image": ImageContent, + "tool_use": ToolUseContent, + "thinking": ThinkingContent, +} + +# Content types allowed in each context +USER_CONTENT_TYPES: Sequence[str] = ("text", "tool_result", "image") +ASSISTANT_CONTENT_TYPES: Sequence[str] = ("text", "tool_use", "thinking") + + +# ============================================================================= +# Conditional Casts +# ============================================================================= + + +def as_user_entry(entry: TranscriptEntry) -> UserTranscriptEntry | None: + """Return entry as UserTranscriptEntry if it is one, else None.""" + if entry.type == MessageType.USER: + return cast(UserTranscriptEntry, entry) + return None + + +def as_assistant_entry(entry: TranscriptEntry) -> AssistantTranscriptEntry | None: + """Return entry as AssistantTranscriptEntry if it is one, else None.""" + if entry.type == MessageType.ASSISTANT: + return cast(AssistantTranscriptEntry, entry) + return None + + +# ============================================================================= +# Usage Info Normalization +# ============================================================================= + + +def normalize_usage_info(usage_data: Any) -> Optional[UsageInfo]: + """Normalize usage data from various formats to UsageInfo.""" + 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 a dict, validate and convert + if isinstance(usage_data, dict): + return UsageInfo.model_validate(usage_data) + + # Handle object-like access (e.g., from SDK types) + if hasattr(usage_data, "input_tokens"): + server_tool_use = getattr(usage_data, "server_tool_use", None) + if server_tool_use is not None and hasattr(server_tool_use, "model_dump"): + server_tool_use = server_tool_use.model_dump() + return UsageInfo( + input_tokens=getattr(usage_data, "input_tokens", None), + output_tokens=getattr(usage_data, "output_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 + ), + service_tier=getattr(usage_data, "service_tier", None), + server_tool_use=server_tool_use, + ) + + return None + + +# ============================================================================= +# Content Item Creation +# ============================================================================= + + +def create_content_item( + item_data: dict[str, Any], + type_filter: Sequence[str] | None = None, +) -> ContentItem: + """Create a ContentItem from raw data using the registry. + + Args: + item_data: The raw dictionary data + type_filter: Sequence of content type strings to allow, or None to allow all + (e.g., USER_CONTENT_TYPES, ASSISTANT_CONTENT_TYPES) + + Returns: + ContentItem instance, with fallback to TextContent for unknown types + """ + try: + content_type = item_data.get("type", "") + + if type_filter is None or content_type in type_filter: + model_class = CONTENT_ITEM_CREATORS.get(content_type) + if model_class is not None: + return cast(ContentItem, model_class.model_validate(item_data)) + + # Fallback to text content for unknown/disallowed types + return TextContent(type="text", text=str(item_data)) + except Exception: + return TextContent(type="text", text=str(item_data)) + + +def create_message_content( + content_data: Any, + type_filter: Sequence[str] | None = None, +) -> list[ContentItem]: + """Create a list of ContentItems from message content data. + + Always returns a list for consistent downstream handling. String content + is wrapped in a TextContent item. + + Args: + content_data: Raw content data (string or list of items) + type_filter: Sequence of content type strings to allow, or None to allow all + """ + if isinstance(content_data, str): + return [TextContent(type="text", text=content_data)] + elif isinstance(content_data, list): + content_list = cast(list[Any], content_data) + result: list[ContentItem] = [] + for item in content_list: + if isinstance(item, dict): + result.append( + create_content_item(cast(dict[str, Any], item), type_filter) + ) + else: + # Non-dict items (e.g., raw strings) become TextContent + result.append(TextContent(type="text", text=str(item))) + return result + else: + return [TextContent(type="text", text=str(content_data))] + + +# ============================================================================= +# Transcript Entry Creation +# ============================================================================= + + +def _create_user_entry(data: dict[str, Any]) -> UserTranscriptEntry: + """Create a UserTranscriptEntry from raw data.""" + 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"] = create_message_content( + data_copy["message"]["content"], + USER_CONTENT_TYPES, + ) + # 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"] = [ + create_content_item(cast(dict[str, Any], item)) + for item in tool_use_result + if isinstance(item, dict) + ] + return UserTranscriptEntry.model_validate(data_copy) + + +def _create_assistant_entry(data: dict[str, Any]) -> AssistantTranscriptEntry: + """Create an AssistantTranscriptEntry from raw data.""" + data_copy = data.copy() + + if "message" in data_copy and "content" in data_copy["message"]: + message_copy = data_copy["message"].copy() + message_copy["content"] = create_message_content( + message_copy["content"], + ASSISTANT_CONTENT_TYPES, + ) + + # 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) + + +def _create_queue_operation_entry( + data: dict[str, Any], +) -> QueueOperationTranscriptEntry: + """Create a QueueOperationTranscriptEntry from raw data.""" + data_copy = data.copy() + if "content" in data_copy and isinstance(data_copy["content"], list): + data_copy["content"] = create_message_content(data_copy["content"]) + return QueueOperationTranscriptEntry.model_validate(data_copy) + + +# Registry mapping entry types to their creator functions +ENTRY_CREATORS: dict[str, Callable[[dict[str, Any]], TranscriptEntry]] = { + "user": _create_user_entry, + "assistant": _create_assistant_entry, + "summary": lambda data: SummaryTranscriptEntry.model_validate(data), + "system": lambda data: SystemTranscriptEntry.model_validate(data), + "queue-operation": _create_queue_operation_entry, +} + + +def create_transcript_entry(data: dict[str, Any]) -> TranscriptEntry: + """Create a TranscriptEntry from a JSON dictionary. + + Uses a registry-based dispatch to create the appropriate TranscriptEntry + subclass based on the 'type' field in the data. + + 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") + creator = ENTRY_CREATORS.get(entry_type) # type: ignore[arg-type] + if creator is None: + raise ValueError(f"Unknown transcript entry type: {entry_type}") + return creator(data) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 35adb8eb..a59c3dc5 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -38,7 +38,7 @@ UserTextMessage, ) from .parser import extract_text_content -from .transcript_parser import ( +from .factories import ( as_assistant_entry, as_user_entry, ) diff --git a/claude_code_log/transcript_parser.py b/claude_code_log/transcript_parser.py deleted file mode 100644 index 7a85c01a..00000000 --- a/claude_code_log/transcript_parser.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Parser for transcript entries and content items. - -This module handles parsing of JSONL transcript data into typed models: -- TranscriptEntry subclasses (User, Assistant, Summary, System, QueueOperation) -- ContentItem subclasses (Text, ToolUse, ToolResult, Thinking, Image) - -Also provides: -- Type guards for TranscriptEntry discrimination -- Usage info normalization for Anthropic SDK compatibility -""" - -from typing import Any, Callable, Optional, cast - -from .models import ( - # Content types - ContentItem, - ImageContent, - TextContent, - ThinkingContent, - ToolResultContent, - ToolUseContent, - # Transcript entry types - AssistantTranscriptEntry, - MessageType, - QueueOperationTranscriptEntry, - SummaryTranscriptEntry, - SystemTranscriptEntry, - TranscriptEntry, - UsageInfo, - UserTranscriptEntry, -) - - -# ============================================================================= -# Type Guards for TranscriptEntry -# ============================================================================= - - -def as_user_entry(entry: TranscriptEntry) -> UserTranscriptEntry | None: - """Return entry as UserTranscriptEntry if it is one, else None.""" - if entry.type == MessageType.USER: - return cast(UserTranscriptEntry, entry) - return None - - -def as_assistant_entry(entry: TranscriptEntry) -> AssistantTranscriptEntry | None: - """Return entry as AssistantTranscriptEntry if it is one, else None.""" - if entry.type == MessageType.ASSISTANT: - return cast(AssistantTranscriptEntry, entry) - return None - - -# ============================================================================= -# Usage Info Normalization -# ============================================================================= - - -def normalize_usage_info(usage_data: Any) -> Optional[UsageInfo]: - """Normalize usage data from various formats to UsageInfo.""" - 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 a dict, validate and convert - if isinstance(usage_data, dict): - return UsageInfo.model_validate(usage_data) - - # Handle object-like access (e.g., from SDK types) - if hasattr(usage_data, "input_tokens"): - server_tool_use = getattr(usage_data, "server_tool_use", None) - if server_tool_use is not None and hasattr(server_tool_use, "model_dump"): - server_tool_use = server_tool_use.model_dump() - return UsageInfo( - input_tokens=getattr(usage_data, "input_tokens", None), - output_tokens=getattr(usage_data, "output_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 - ), - service_tier=getattr(usage_data, "service_tier", None), - server_tool_use=server_tool_use, - ) - - 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. - - Common to both user and assistant messages. - """ - 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. - - 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": - return ToolUseContent.model_validate(item_data) - elif content_type == "thinking": - 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", "") - - if content_type == "tool_result": - return ToolResultContent.model_validate(item_data) - elif content_type == "image": - return ImageContent.model_validate(item_data) - elif content_type == "tool_use": - return ToolUseContent.model_validate(item_data) - elif content_type == "thinking": - return ThinkingContent.model_validate(item_data) - 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, -) -> list[ContentItem]: - """Parse message content, normalizing to a list of ContentItems. - - Always returns a list for consistent downstream handling. String content - is wrapped in a TextContent item. - - 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 [TextContent(type="text", text=content_data)] - elif isinstance(content_data, list): - content_list = cast(list[Any], content_data) - result: list[ContentItem] = [] - for item in content_list: - if isinstance(item, dict): - result.append(item_parser(cast(dict[str, Any], item))) - else: - # Non-dict items (e.g., raw strings) become TextContent - result.append(TextContent(type="text", text=str(item))) - return result - else: - return [TextContent(type="text", text=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": - data_copy = data.copy() - - # Parse assistant message content - 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/test/test_context_command.py b/test/test_context_command.py index 95c025e9..6b7b6ab9 100644 --- a/test/test_context_command.py +++ b/test/test_context_command.py @@ -1,7 +1,7 @@ """Tests for /context command output rendering.""" +from claude_code_log.factories import create_transcript_entry from claude_code_log.html.renderer import generate_html -from claude_code_log.transcript_parser import parse_transcript_entry def test_context_command_rendering(): @@ -33,7 +33,7 @@ def test_context_command_rendering(): ] # Parse the raw messages into TranscriptEntry objects - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # Check that ANSI codes were converted to HTML spans with proper classes @@ -91,7 +91,7 @@ def test_context_command_without_ansi(): ] # Parse the raw messages into TranscriptEntry objects - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # Check that text is properly rendered even without ANSI codes @@ -130,7 +130,7 @@ def test_mixed_ansi_and_plain_text(): ] # Parse the raw messages into TranscriptEntry objects - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # Check that both plain and colored text are present diff --git a/test/test_date_filtering.py b/test/test_date_filtering.py index 794554f4..4dc3e448 100644 --- a/test/test_date_filtering.py +++ b/test/test_date_filtering.py @@ -7,7 +7,7 @@ from pathlib import Path from claude_code_log.converter import convert_jsonl_to_html from claude_code_log.converter import filter_messages_by_date -from claude_code_log.transcript_parser import parse_transcript_entry +from claude_code_log.factories import create_transcript_entry def create_test_message(timestamp_str: str, text: str) -> dict: @@ -47,7 +47,7 @@ def to_utc_iso(dt: datetime) -> str: ] # Parse dictionaries into TranscriptEntry objects - messages = [parse_transcript_entry(msg_dict) for msg_dict in message_dicts] + messages = [create_transcript_entry(msg_dict) for msg_dict in message_dicts] # Test filtering from yesterday onwards filtered = filter_messages_by_date(messages, "yesterday", None) @@ -78,7 +78,7 @@ def to_utc_iso(dt: datetime) -> str: def test_invalid_date_handling(): """Test handling of invalid date strings.""" messages = [ - parse_transcript_entry( + create_transcript_entry( create_test_message("2025-06-08T12:00:00Z", "Test message") ) ] @@ -163,7 +163,7 @@ def test_natural_language_dates(): """Test various natural language date formats.""" message_dict = create_test_message("2025-06-08T12:00:00Z", "Test message") - messages = [parse_transcript_entry(message_dict)] + messages = [create_transcript_entry(message_dict)] # Test various natural language formats date_formats = ["today", "yesterday", "last week", "3 days ago", "1 week ago"] diff --git a/test/test_hook_summary.py b/test/test_hook_summary.py index 71bce7b8..2873a740 100644 --- a/test/test_hook_summary.py +++ b/test/test_hook_summary.py @@ -1,7 +1,7 @@ """Tests for hook summary (stop_hook_summary) parsing and rendering.""" +from claude_code_log.factories import create_transcript_entry from claude_code_log.models import SystemTranscriptEntry -from claude_code_log.transcript_parser import parse_transcript_entry from claude_code_log.html.renderer import generate_html @@ -30,7 +30,7 @@ def test_parse_hook_summary_without_content(self): "uuid": "test-uuid", } - entry = parse_transcript_entry(data) + entry = create_transcript_entry(data) assert isinstance(entry, SystemTranscriptEntry) assert entry.subtype == "stop_hook_summary" @@ -66,7 +66,7 @@ def test_parse_hook_summary_with_errors(self): "uuid": "test-uuid", } - entry = parse_transcript_entry(data) + entry = create_transcript_entry(data) assert isinstance(entry, SystemTranscriptEntry) assert entry.subtype == "stop_hook_summary" @@ -91,7 +91,7 @@ def test_parse_system_message_with_content_still_works(self): "uuid": "test-uuid", } - entry = parse_transcript_entry(data) + entry = create_transcript_entry(data) assert isinstance(entry, SystemTranscriptEntry) assert entry.content == "init" @@ -124,7 +124,7 @@ def test_silent_hook_success_not_rendered(self): } ] - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # Should not contain actual hook content (skipped) @@ -156,7 +156,7 @@ def test_hook_with_errors_rendered(self): } ] - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # Should contain hook summary elements @@ -188,7 +188,7 @@ def test_hook_with_output_but_no_errors_rendered(self): } ] - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # Should contain hook summary elements @@ -218,7 +218,7 @@ def test_hook_with_ansi_errors_rendered(self): } ] - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # ANSI codes should be converted, not present raw @@ -243,7 +243,7 @@ def test_regular_system_message_still_renders(self): } ] - parsed_messages = [parse_transcript_entry(msg) for msg in messages] + parsed_messages = [create_transcript_entry(msg) for msg in messages] html = generate_html(parsed_messages) # Should render the command name diff --git a/test/test_toggle_functionality.py b/test/test_toggle_functionality.py index eca944d1..7f239e12 100644 --- a/test/test_toggle_functionality.py +++ b/test/test_toggle_functionality.py @@ -1,12 +1,12 @@ """Tests for the collapsible details toggle functionality.""" from typing import Any, Dict, List +from claude_code_log.factories import create_content_item from claude_code_log.models import ( AssistantTranscriptEntry, AssistantMessageModel, UsageInfo, ) -from claude_code_log.transcript_parser import parse_content_item from claude_code_log.html.renderer import generate_html @@ -17,8 +17,8 @@ def _create_assistant_message( self, content_items: List[Dict[str, Any]] ) -> AssistantTranscriptEntry: """Helper to create a properly structured AssistantTranscriptEntry.""" - # Convert raw content items to proper ContentItem objects - parsed_content = [parse_content_item(item) for item in content_items] + # Convert raw content items to proper ContentItem objects (no filter = all types) + parsed_content = [create_content_item(item) for item in content_items] # Create AssistantMessageModel with proper types message = AssistantMessageModel( From 09521b7c28a74a9b6043d2cf3746af16be309b18 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 18:08:26 +0100 Subject: [PATCH 16/57] Move system_parser.py to factories/system_factory.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename parse_system_transcript to create_system_message - Update all imports in renderer.py, utils.py, and test files - Delete old system_parser.py (no redirect needed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/__init__.py | 10 ++++++++++ .../system_factory.py} | 16 ++++++++-------- claude_code_log/renderer.py | 12 ++++++------ claude_code_log/utils.py | 2 +- test/test_message_filtering.py | 2 +- test/test_utils.py | 2 +- 6 files changed, 27 insertions(+), 17 deletions(-) rename claude_code_log/{system_parser.py => factories/system_factory.py} (88%) diff --git a/claude_code_log/factories/__init__.py b/claude_code_log/factories/__init__.py index 188bb4e0..5599c7bb 100644 --- a/claude_code_log/factories/__init__.py +++ b/claude_code_log/factories/__init__.py @@ -1,5 +1,11 @@ """Factory modules for creating typed objects from raw data.""" +from .system_factory import ( + # System message detection + is_system_message, + # System message creation + create_system_message, +) from .transcript_factory import ( # Content type constants ASSISTANT_CONTENT_TYPES, @@ -30,4 +36,8 @@ "create_message_content", # Transcript entry creation "create_transcript_entry", + # System message detection + "is_system_message", + # System message creation + "create_system_message", ] diff --git a/claude_code_log/system_parser.py b/claude_code_log/factories/system_factory.py similarity index 88% rename from claude_code_log/system_parser.py rename to claude_code_log/factories/system_factory.py index 82ba3576..506d5add 100644 --- a/claude_code_log/system_parser.py +++ b/claude_code_log/factories/system_factory.py @@ -1,6 +1,6 @@ -"""Parser for system transcript entries. +"""Factory for system transcript entries. -This module handles parsing of SystemTranscriptEntry into MessageContent subclasses: +This module handles creation of MessageContent from SystemTranscriptEntry: - SystemMessage: Regular system messages with level (info, warning, error) - HookSummaryMessage: Hook execution summaries @@ -10,13 +10,13 @@ from typing import Optional, Union -from .models import ( +from ..models import ( HookInfo, HookSummaryMessage, SystemMessage, SystemTranscriptEntry, ) -from .parser import parse_meta +from ..parser import parse_meta # ============================================================================= @@ -36,21 +36,21 @@ def is_system_message(text_content: str) -> bool: # ============================================================================= -# System Transcript Parsing +# System Message Creation # ============================================================================= -def parse_system_transcript( +def create_system_message( transcript: SystemTranscriptEntry, ) -> Optional[Union[SystemMessage, HookSummaryMessage]]: - """Parse a system transcript entry into a MessageContent. + """Create a MessageContent from a system transcript entry. Handles: - Hook summaries (subtype="stop_hook_summary") - Regular system messages with level-specific styling (info, warning, error) Args: - transcript: The system transcript entry to parse + transcript: The system transcript entry to process Returns: SystemMessage or HookSummaryMessage (with meta attached), diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index a59c3dc5..70f53e33 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -626,10 +626,10 @@ def prepare_session_navigation( # -- Message Processing Functions --------------------------------------------- -# Note: Message parsing functions have been moved to dedicated parser modules: +# Note: Message parsing functions have been moved to dedicated modules: # - user_parser.py: parse_user_message_content, parse_slash_command, etc. # - assistant_parser.py: parse_assistant_message_content, parse_thinking_item -# - system_parser.py: parse_system_transcript +# - factories/system_factory.py: create_system_message def _process_system_message( @@ -651,16 +651,16 @@ def _process_system_message( not system messages. They are handled by _process_command_message and _process_local_command_output in the main processing loop. """ - from .system_parser import parse_system_transcript + from .factories import create_system_message - # Parse the transcript entry into structured message (with meta attached) - message = parse_system_transcript(transcript) + # Create structured message content (with meta attached) + message = create_system_message(transcript) if message is None: return None # Get metadata from the message content meta = message.meta - assert meta is not None, "parse_system_transcript should always set meta" + assert meta is not None, "create_system_message should always set meta" # Get title from message (uses message_title() method) title = message.message_title() or "System" diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index d0ba7930..056a087d 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -8,7 +8,7 @@ from claude_code_log.cache import SessionCacheData from .models import ContentItem, TextContent, TranscriptEntry, UserTranscriptEntry -from .system_parser import is_system_message +from .factories import is_system_message from .user_parser import ( IDE_DIAGNOSTICS_PATTERN, IDE_OPENED_FILE_PATTERN, diff --git a/test/test_message_filtering.py b/test/test_message_filtering.py index 03869980..bba56763 100644 --- a/test/test_message_filtering.py +++ b/test/test_message_filtering.py @@ -6,7 +6,7 @@ from pathlib import Path from claude_code_log.converter import load_transcript from claude_code_log.html.renderer import generate_html -from claude_code_log.system_parser import is_system_message +from claude_code_log.factories import is_system_message def test_caveat_message_filtering(): diff --git a/test/test_utils.py b/test/test_utils.py index 3dc099ed..41fab9ba 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -2,7 +2,7 @@ """Test cases for the utils module functions.""" import pytest -from claude_code_log.system_parser import is_system_message +from claude_code_log.factories import is_system_message from claude_code_log.user_parser import ( is_bash_input, is_bash_output, From 9278df24d058b628c610ddb36a38fba25ede4a99 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 18:17:40 +0100 Subject: [PATCH 17/57] Extend MessageMeta and rename parse_meta to create_meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add gitBranch field to BaseTranscriptEntry - Extend MessageMeta with context fields: - is_sidechain, agent_id, cwd, git_branch - Create factories/meta_factory.py with create_meta - Remove parse_meta from parser.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/__init__.py | 6 ++++ claude_code_log/factories/meta_factory.py | 32 +++++++++++++++++++ claude_code_log/factories/system_factory.py | 6 ++-- claude_code_log/models.py | 8 +++++ claude_code_log/parser.py | 34 ++------------------- 5 files changed, 51 insertions(+), 35 deletions(-) create mode 100644 claude_code_log/factories/meta_factory.py diff --git a/claude_code_log/factories/__init__.py b/claude_code_log/factories/__init__.py index 5599c7bb..8eca3573 100644 --- a/claude_code_log/factories/__init__.py +++ b/claude_code_log/factories/__init__.py @@ -1,5 +1,9 @@ """Factory modules for creating typed objects from raw data.""" +from .meta_factory import ( + # Metadata creation + create_meta, +) from .system_factory import ( # System message detection is_system_message, @@ -23,6 +27,8 @@ ) __all__ = [ + # Metadata creation + "create_meta", # Content type constants "USER_CONTENT_TYPES", "ASSISTANT_CONTENT_TYPES", diff --git a/claude_code_log/factories/meta_factory.py b/claude_code_log/factories/meta_factory.py new file mode 100644 index 00000000..8dc46740 --- /dev/null +++ b/claude_code_log/factories/meta_factory.py @@ -0,0 +1,32 @@ +"""Factory for creating MessageMeta from transcript entries. + +This module handles extraction of common metadata from transcript entries +that is shared across all message types. +""" + +from ..models import BaseTranscriptEntry, MessageMeta + + +def create_meta(transcript: BaseTranscriptEntry) -> MessageMeta: + """Create MessageMeta from a transcript entry. + + Extracts all shared fields from BaseTranscriptEntry subclasses. + + Args: + transcript: Any transcript entry inheriting from BaseTranscriptEntry + + Returns: + MessageMeta with identity and context fields + """ + return MessageMeta( + # Identity fields + session_id=transcript.sessionId, + timestamp=transcript.timestamp, + uuid=transcript.uuid, + parent_uuid=transcript.parentUuid, + # Context fields + is_sidechain=transcript.isSidechain, + agent_id=transcript.agentId, + cwd=transcript.cwd, + git_branch=transcript.gitBranch, + ) diff --git a/claude_code_log/factories/system_factory.py b/claude_code_log/factories/system_factory.py index 506d5add..3ec9c785 100644 --- a/claude_code_log/factories/system_factory.py +++ b/claude_code_log/factories/system_factory.py @@ -16,7 +16,7 @@ SystemMessage, SystemTranscriptEntry, ) -from ..parser import parse_meta +from .meta_factory import create_meta # ============================================================================= @@ -65,7 +65,7 @@ def create_system_message( if not transcript.hasOutput and not transcript.hookErrors: return None # Create structured hook summary content - meta = parse_meta(transcript) + meta = create_meta(transcript) hook_infos = [ HookInfo(command=info.get("command", "unknown")) for info in (transcript.hookInfos or []) @@ -82,6 +82,6 @@ def create_system_message( return None # Create structured system content - meta = parse_meta(transcript) + meta = create_meta(transcript) level = getattr(transcript, "level", "info") return SystemMessage(level=level, text=transcript.content, meta=meta) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index f717817b..75e35ad1 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -61,11 +61,18 @@ class MessageMeta: Note: formatted_timestamp is computed at render time, not stored here. """ + # Identity fields session_id: str timestamp: str # Raw ISO timestamp uuid: str parent_uuid: Optional[str] = None + # Context fields + is_sidechain: bool = False + agent_id: Optional[str] = None + cwd: str = "" + git_branch: Optional[str] = None + # ============================================================================= # Message Content Models @@ -904,6 +911,7 @@ class BaseTranscriptEntry(BaseModel): timestamp: str isMeta: Optional[bool] = None agentId: Optional[str] = None # Agent ID for sidechain messages + gitBranch: Optional[str] = None # Git branch name when available class UserTranscriptEntry(BaseTranscriptEntry): diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index e17f74d7..270fb265 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -2,46 +2,16 @@ """Parse and extract data from Claude transcript JSONL files. This module provides utility functions for parsing transcript data: -- parse_meta: Extract common metadata from transcript entries - extract_text_content: Extract text from content items - parse_timestamp: Parse ISO timestamps -For transcript entry and content item parsing, see transcript_parser.py. +For transcript entry and content item creation, see factories/. """ from datetime import datetime from typing import Optional -from .models import ( - # Common metadata - BaseTranscriptEntry, - MessageMeta, - # Content types - ContentItem, - ThinkingContent, -) - - -def parse_meta(transcript: BaseTranscriptEntry) -> MessageMeta: - """Extract common metadata from a transcript entry. - - This function extracts the shared fields that are present in all - BaseTranscriptEntry subclasses. - - Note: formatted_timestamp is computed at render time, not here. - - Args: - transcript: Any transcript entry inheriting from BaseTranscriptEntry - - Returns: - MessageMeta with session_id, timestamp, uuid, and parent_uuid - """ - return MessageMeta( - session_id=transcript.sessionId, - timestamp=transcript.timestamp, - uuid=transcript.uuid, - parent_uuid=transcript.parentUuid, - ) +from .models import ContentItem, ThinkingContent def extract_text_content(content: Optional[list[ContentItem]]) -> str: From 1fd534842f70f23009390520668754bab7c9733e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 18:45:03 +0100 Subject: [PATCH 18/57] Move user_parser.py to factories/user_factory.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename parse_* functions to create_* - Consolidate type detection into create_user_message - Create meta once outside chunks loop in _render_messages - Determine effective_type early before chunk processing - Update all imports and delete old user_parser.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/__init__.py | 40 +++++ .../user_factory.py} | 151 ++++++++++++------ claude_code_log/html/__init__.py | 24 +-- claude_code_log/renderer.py | 86 +++++----- claude_code_log/utils.py | 4 +- test/test_ide_tags.py | 51 +++--- test/test_template_utils.py | 22 +-- test/test_user_renderer.py | 87 +++++----- test/test_utils.py | 4 +- 9 files changed, 284 insertions(+), 185 deletions(-) rename claude_code_log/{user_parser.py => factories/user_factory.py} (74%) diff --git a/claude_code_log/factories/__init__.py b/claude_code_log/factories/__init__.py index 8eca3573..2469c66f 100644 --- a/claude_code_log/factories/__init__.py +++ b/claude_code_log/factories/__init__.py @@ -10,6 +10,27 @@ # System message creation create_system_message, ) +from .user_factory import ( + # User message type detection + is_bash_input, + is_bash_output, + is_command_message, + is_local_command_output, + # User message creation + create_bash_input_message, + create_bash_output_message, + create_command_output_message, + create_compacted_summary_message, + create_ide_notification_content, + create_slash_command_message, + create_user_memory_message, + create_user_message, + # Patterns and constants + COMPACTED_SUMMARY_PREFIX, + IDE_DIAGNOSTICS_PATTERN, + IDE_OPENED_FILE_PATTERN, + IDE_SELECTION_PATTERN, +) from .transcript_factory import ( # Content type constants ASSISTANT_CONTENT_TYPES, @@ -46,4 +67,23 @@ "is_system_message", # System message creation "create_system_message", + # User message type detection + "is_bash_input", + "is_bash_output", + "is_command_message", + "is_local_command_output", + # User message creation + "create_bash_input_message", + "create_bash_output_message", + "create_command_output_message", + "create_compacted_summary_message", + "create_ide_notification_content", + "create_slash_command_message", + "create_user_memory_message", + "create_user_message", + # Patterns and constants + "COMPACTED_SUMMARY_PREFIX", + "IDE_DIAGNOSTICS_PATTERN", + "IDE_OPENED_FILE_PATTERN", + "IDE_SELECTION_PATTERN", ] diff --git a/claude_code_log/user_parser.py b/claude_code_log/factories/user_factory.py similarity index 74% rename from claude_code_log/user_parser.py rename to claude_code_log/factories/user_factory.py index 42a418c0..4228097c 100644 --- a/claude_code_log/user_parser.py +++ b/claude_code_log/factories/user_factory.py @@ -1,6 +1,6 @@ -"""Parser for user transcript entries. +"""Factory for user transcript entries. -This module handles parsing of UserTranscriptEntry content into MessageContent subclasses: +This module handles creation of MessageContent from user transcript entries: - SlashCommandMessage: Slash command invocations - CommandOutputMessage: Local command output - BashInputMessage: Bash command input @@ -10,13 +10,19 @@ - CompactedSummaryMessage: Compacted conversation summaries - UserMemoryMessage: User memory content - UserSteeringMessage: User steering prompts (queue-operation 'remove') + +Also provides: +- is_command_message: Check if text is a slash command +- is_local_command_output: Check if text is local command output +- is_bash_input: Check if text is bash input +- is_bash_output: Check if text is bash output """ import json import re from typing import Any, Optional, Union, cast -from .models import ( +from ..models import ( BashInputMessage, BashOutputMessage, CommandOutputMessage, @@ -27,6 +33,7 @@ IdeOpenedFile, IdeSelection, ImageContent, + MessageMeta, SlashCommandMessage, TextContent, UserMemoryMessage, @@ -61,15 +68,19 @@ def is_bash_output(text_content: str) -> bool: # ============================================================================= -# Slash Command Parsing +# Slash Command Creation # ============================================================================= -def parse_slash_command(text: str) -> Optional[SlashCommandMessage]: - """Parse slash command tags from text. +def create_slash_command_message( + text: str, + meta: Optional[MessageMeta] = None, +) -> Optional[SlashCommandMessage]: + """Create SlashCommandMessage from text containing command tags. Args: text: Raw text that may contain command-name, command-args, command-contents tags + meta: Message metadata Returns: SlashCommandMessage if tags found, None otherwise @@ -106,14 +117,19 @@ def parse_slash_command(text: str) -> Optional[SlashCommandMessage]: command_name=command_name, command_args=command_args, command_contents=command_contents, + meta=meta, ) -def parse_command_output(text: str) -> Optional[CommandOutputMessage]: - """Parse command output tags from text. +def create_command_output_message( + text: str, + meta: Optional[MessageMeta] = None, +) -> Optional[CommandOutputMessage]: + """Create CommandOutputMessage from text containing local-command-stdout tags. Args: text: Raw text that may contain local-command-stdout tags + meta: Message metadata Returns: CommandOutputMessage if tags found, None otherwise @@ -130,19 +146,25 @@ def parse_command_output(text: str) -> Optional[CommandOutputMessage]: # Check if content looks like markdown (starts with markdown headers) is_markdown = bool(re.match(r"^#+\s+", stdout_content, re.MULTILINE)) - return CommandOutputMessage(stdout=stdout_content, is_markdown=is_markdown) + return CommandOutputMessage( + stdout=stdout_content, is_markdown=is_markdown, meta=meta + ) # ============================================================================= -# Bash Input/Output Parsing +# Bash Input/Output Creation # ============================================================================= -def parse_bash_input(text: str) -> Optional[BashInputMessage]: - """Parse bash input tags from text. +def create_bash_input_message( + text: str, + meta: Optional[MessageMeta] = None, +) -> Optional[BashInputMessage]: + """Create BashInputMessage from text containing bash-input tags. Args: text: Raw text that may contain bash-input tags + meta: Message metadata Returns: BashInputMessage if tags found, None otherwise @@ -151,14 +173,18 @@ def parse_bash_input(text: str) -> Optional[BashInputMessage]: if not bash_match: return None - return BashInputMessage(command=bash_match.group(1).strip()) + return BashInputMessage(command=bash_match.group(1).strip(), meta=meta) -def parse_bash_output(text: str) -> Optional[BashOutputMessage]: - """Parse bash output tags from text. +def create_bash_output_message( + text: str, + meta: Optional[MessageMeta] = None, +) -> Optional[BashOutputMessage]: + """Create BashOutputMessage from text containing bash-stdout/bash-stderr tags. Args: text: Raw text that may contain bash-stdout/bash-stderr tags + meta: Message metadata Returns: BashOutputMessage if tags found, None otherwise @@ -178,11 +204,11 @@ def parse_bash_output(text: str) -> Optional[BashOutputMessage]: if stderr == "": stderr = None - return BashOutputMessage(stdout=stdout, stderr=stderr) + return BashOutputMessage(stdout=stdout, stderr=stderr, meta=meta) # ============================================================================= -# IDE Notification Parsing +# IDE Notification Creation # ============================================================================= # Shared regex patterns for IDE notification tags @@ -196,8 +222,8 @@ def parse_bash_output(text: str) -> Optional[BashOutputMessage]: ) -def parse_ide_notifications(text: str) -> Optional[IdeNotificationContent]: - """Parse IDE notification tags from text. +def create_ide_notification_content(text: str) -> Optional[IdeNotificationContent]: + """Create IdeNotificationContent from text containing IDE tags. Handles: - : Simple file open notifications @@ -262,17 +288,18 @@ def parse_ide_notifications(text: str) -> Optional[IdeNotificationContent]: # ============================================================================= -# Compacted Summary and User Memory Parsing +# Compacted Summary and User Memory Creation # ============================================================================= # 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( +def create_compacted_summary_message( content_list: list[ContentItem], + meta: Optional[MessageMeta] = None, ) -> Optional[CompactedSummaryMessage]: - """Parse compacted session summary from content list. + """Create CompactedSummaryMessage 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. @@ -282,6 +309,7 @@ def parse_compacted_summary( Args: content_list: List of ContentItem from user message + meta: Message metadata Returns: CompactedSummaryMessage if first text is a compacted summary, None otherwise @@ -300,7 +328,7 @@ def parse_compacted_summary( [item.text for item in content_list if hasattr(item, "text")], # type: ignore[union-attr] ) all_text = "\n\n".join(texts) - return CompactedSummaryMessage(summary_text=all_text) + return CompactedSummaryMessage(summary_text=all_text, meta=meta) # Pattern for user memory input tag @@ -309,14 +337,18 @@ def parse_compacted_summary( ) -def parse_user_memory(text: str) -> Optional[UserMemoryMessage]: - """Parse user memory input tag from text. +def create_user_memory_message( + text: str, + meta: Optional[MessageMeta] = None, +) -> Optional[UserMemoryMessage]: + """Create UserMemoryMessage from text containing user-memory-input tag. 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 + meta: Message metadata Returns: UserMemoryMessage if tag found, None otherwise @@ -324,41 +356,51 @@ def parse_user_memory(text: str) -> Optional[UserMemoryMessage]: match = USER_MEMORY_PATTERN.search(text) if match: memory_content = match.group(1).strip() - return UserMemoryMessage(memory_text=memory_content) + return UserMemoryMessage(memory_text=memory_content, meta=meta) return None # ============================================================================= -# User Message Content Parsing +# User Message Content Creation # ============================================================================= -# Type alias for content models returned by parse_user_message_content +# Type alias for content models returned by create_user_message UserMessageContent = Union[ - CompactedSummaryMessage, UserMemoryMessage, UserSlashCommandMessage, UserTextMessage + SlashCommandMessage, + CommandOutputMessage, + BashInputMessage, + BashOutputMessage, + CompactedSummaryMessage, + UserMemoryMessage, + UserSlashCommandMessage, + UserTextMessage, ] -def parse_user_message_content( +def create_user_message( content_list: list[ContentItem], + text_content: str, is_slash_command: bool = False, + meta: Optional[MessageMeta] = None, ) -> 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: - - UserSlashCommandMessage: Slash command expanded prompts (isMeta=True) - - CompactedSummaryMessage: Session continuation summaries - - UserMemoryMessage: User memory input from CLAUDE.md - - UserTextMessage: Normal user text with optional IDE notifications and images - - This function processes content items preserving their original order: - - TextContent items have IDE notifications extracted, producing - [IdeNotificationContent, TextContent] pairs - - ImageContent items are preserved as-is + """Create a user message content model from content items. + + This is the main entry point for creating user message content. + It handles all user message types by detecting patterns in the text: + - Slash commands (, ) + - Local command output () + - Bash input () + - Bash output (, ) + - Compacted summaries (special prefix) + - User memory () + - Slash command expanded prompts (isMeta=True) + - Regular user text with IDE notifications Args: content_list: List of ContentItem from user message + text_content: Pre-extracted text content for pattern detection is_slash_command: True for slash command expanded prompts (isMeta=True) + meta: Message metadata Returns: A content model, or None if content_list is empty. @@ -366,12 +408,25 @@ def parse_user_message_content( if not content_list: return None + # Check for special message patterns first (before generic parsing) + if is_command_message(text_content): + return create_slash_command_message(text_content, meta=meta) + + if is_local_command_output(text_content): + return create_command_output_message(text_content, meta=meta) + + if is_bash_input(text_content): + return create_bash_input_message(text_content, meta=meta) + + if is_bash_output(text_content): + return create_bash_output_message(text_content, meta=meta) + # Slash command expanded prompts - combine all text as markdown if is_slash_command: all_text = "\n\n".join( getattr(item, "text", "") for item in content_list if hasattr(item, "text") ) - return UserSlashCommandMessage(text=all_text) if all_text else None + return UserSlashCommandMessage(text=all_text, meta=meta) if all_text else None # Get first text item for special case detection first_text_item = next( @@ -381,12 +436,12 @@ def parse_user_message_content( first_text = getattr(first_text_item, "text", "") if first_text_item else "" # Check for compacted session summary first (handles text combining internally) - compacted = parse_compacted_summary(content_list) + compacted = create_compacted_summary_message(content_list, meta=meta) if compacted: return compacted # Check for user memory input - user_memory = parse_user_memory(first_text) + user_memory = create_user_memory_message(first_text, meta=meta) if user_memory: return user_memory @@ -397,7 +452,7 @@ def parse_user_message_content( # Check for text content if hasattr(item, "text"): item_text: str = getattr(item, "text") # type: ignore[assignment] - ide_content = parse_ide_notifications(item_text) + ide_content = create_ide_notification_content(item_text) if ide_content: # Add IDE notification item first @@ -417,4 +472,4 @@ def parse_user_message_content( items.append(ImageContent.model_validate(item.model_dump())) # type: ignore[union-attr] # Return UserTextMessage with items list - return UserTextMessage(items=items) + return UserTextMessage(items=items, meta=meta) diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py index 430a9802..8b25e352 100644 --- a/claude_code_log/html/__init__.py +++ b/claude_code_log/html/__init__.py @@ -59,12 +59,12 @@ UserMemoryMessage, UserTextMessage, ) -from ..user_parser import ( - parse_bash_input, - parse_bash_output, - parse_command_output, - parse_ide_notifications, - parse_slash_command, +from ..factories import ( + create_bash_input_message, + create_bash_output_message, + create_command_output_message, + create_ide_notification_content, + create_slash_command_message, ) from .user_formatters import ( format_bash_input_content, @@ -146,12 +146,12 @@ "format_user_text_content", "format_user_text_model_content", "format_ide_notification_content", - # user_formatters (parsing) - "parse_slash_command", - "parse_command_output", - "parse_bash_input", - "parse_bash_output", - "parse_ide_notifications", + # user_factory (message creation) + "create_slash_command_message", + "create_command_output_message", + "create_bash_input_message", + "create_bash_output_message", + "create_ide_notification_content", # assistant_formatters (content models) "AssistantTextMessage", "ThinkingMessage", diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 70f53e33..9146bf62 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -12,6 +12,7 @@ from datetime import datetime from .models import ( + BaseTranscriptEntry, MessageType, TranscriptEntry, AssistantTranscriptEntry, @@ -41,17 +42,8 @@ from .factories import ( as_assistant_entry, as_user_entry, -) -from .user_parser import ( - is_bash_input, - is_bash_output, - is_command_message, - is_local_command_output, - parse_bash_input, - parse_bash_output, - parse_command_output, - parse_slash_command, - parse_user_message_content, + create_meta, + create_user_message, ) from .assistant_parser import ( parse_assistant_message_content, @@ -1693,6 +1685,20 @@ def _render_messages( if not chunks: continue + # Create meta once for all chunks from this message + # (QueueOperationTranscriptEntry doesn't have BaseTranscriptEntry fields) + if isinstance(message, BaseTranscriptEntry): + meta = create_meta(message) + else: + meta = None + + # Determine effective_type for dispatching to user/assistant parsers + # (queue-operation 'remove' messages are treated as user messages) + if isinstance(message, QueueOperationTranscriptEntry): + effective_type = "user" + else: + effective_type = message_type + # Get session info session_id = getattr(message, "sessionId", "unknown") session_summary = getattr(message, "_session_summary", None) @@ -1764,48 +1770,32 @@ def _render_messages( # Extract text for pattern detection chunk_text = extract_text_content(chunk) - # Check for special message patterns - is_command = is_command_message(chunk_text) - is_local_output = is_local_command_output(chunk_text) - is_bash_cmd = is_bash_input(chunk_text) - is_bash_result = is_bash_output(chunk_text) - # Determine is_sidechain and content based on message type content_model: Optional[MessageContent] = None chunk_message_type = message_type chunk_is_sidechain = getattr(message, "isSidechain", False) - if is_command: - content_model = parse_slash_command(chunk_text) - elif is_local_output: - content_model = parse_command_output(chunk_text) - elif is_bash_cmd: - content_model = parse_bash_input(chunk_text) - elif is_bash_result: - content_model = parse_bash_output(chunk_text) - else: - # For queue-operation messages, treat them as user messages - if isinstance(message, QueueOperationTranscriptEntry): - effective_type = "user" - else: - effective_type = message_type - - # Dispatch to user or assistant parser based on message type - if effective_type == MessageType.USER: - content_model = parse_user_message_content( - chunk, # Pass the chunk items - is_slash_command=getattr(message, "isMeta", False), - ) - elif effective_type == MessageType.ASSISTANT: - content_model = parse_assistant_message_content(chunk) - - # Convert to UserSteeringMessage for queue-operation 'remove' messages - if ( - isinstance(message, QueueOperationTranscriptEntry) - and message.operation == "remove" - and isinstance(content_model, UserTextMessage) - ): - content_model = UserSteeringMessage(items=content_model.items) + # Dispatch to user or assistant parser based on effective_type + # (user message parsing handles all type detection internally) + if effective_type == "user": + content_model = create_user_message( + chunk, # Pass the chunk items + chunk_text, # Pre-extracted text for pattern detection + is_slash_command=getattr(message, "isMeta", False), + meta=meta, + ) + elif effective_type == "assistant": + content_model = parse_assistant_message_content(chunk) + + # Convert to UserSteeringMessage for queue-operation 'remove' messages + if ( + isinstance(message, QueueOperationTranscriptEntry) + and message.operation == "remove" + and isinstance(content_model, UserTextMessage) + ): + content_model = UserSteeringMessage( + items=content_model.items, meta=meta + ) # Get message_type and message_title from content_model if content_model is not None: diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index 056a087d..88910e89 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -8,13 +8,13 @@ from claude_code_log.cache import SessionCacheData from .models import ContentItem, TextContent, TranscriptEntry, UserTranscriptEntry -from .factories import is_system_message -from .user_parser import ( +from .factories import ( IDE_DIAGNOSTICS_PATTERN, IDE_OPENED_FILE_PATTERN, IDE_SELECTION_PATTERN, is_command_message, is_local_command_output, + is_system_message, ) diff --git a/test/test_ide_tags.py b/test/test_ide_tags.py index 8059c47d..bb7034b7 100644 --- a/test/test_ide_tags.py +++ b/test/test_ide_tags.py @@ -1,16 +1,17 @@ """Tests for IDE tag parsing and formatting in user messages. Split into: -- Parsing tests: test parse_ide_notifications() from user_parser.py +- Parsing tests: test create_ide_notification_content() from factories - Formatting tests: test format_ide_notification_content() from user_formatters.py -- User message tests: test parse_user_message_content() and formatters +- User message tests: test create_user_message() and formatters - Assistant text tests: test format_assistant_text_content() """ -from claude_code_log.user_parser import ( - parse_ide_notifications, - parse_user_message_content, +from claude_code_log.factories import ( + create_ide_notification_content, + create_user_message, ) +from claude_code_log.parser import extract_text_content from claude_code_log.html.user_formatters import ( format_ide_notification_content, format_user_text_content, @@ -27,12 +28,12 @@ # ============================================================================= -# Parsing Tests - parse_ide_notifications() +# Parsing Tests - create_ide_notification_content() # ============================================================================= class TestParseIdeNotifications: - """Tests for parse_ide_notifications() parser function.""" + """Tests for create_ide_notification_content() parser function.""" def test_parse_ide_opened_file_tag(self): """Test that tags are parsed correctly.""" @@ -43,7 +44,7 @@ def test_parse_ide_opened_file_tag(self): "Here is my actual question." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None assert len(result.opened_files) == 1 @@ -58,7 +59,7 @@ def test_parse_multiple_ide_tags(self): "Second file opened." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None assert len(result.opened_files) == 2 @@ -71,7 +72,7 @@ def test_parse_no_ide_tags(self): """Test that messages without IDE tags return None.""" text = "This is a regular user message without any IDE tags." - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is None @@ -84,7 +85,7 @@ def test_parse_multiline_ide_tag(self): "User question follows." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None assert len(result.opened_files) == 1 @@ -103,7 +104,7 @@ def test_parse_ide_diagnostics(self): "Here is my question." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None assert len(result.diagnostics) == 1 @@ -125,7 +126,7 @@ def test_parse_ide_selection_short(self): "Can you explain this?" ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None assert len(result.selections) == 1 @@ -144,7 +145,7 @@ def test_parse_all_ide_tag_types(self): "Please help." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None assert len(result.opened_files) == 1 @@ -173,7 +174,7 @@ def test_format_ide_opened_file_tag(self): "Question here." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None notifications = format_ide_notification_content(result) @@ -189,7 +190,7 @@ def test_format_multiple_ide_tags(self): "Second file opened." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None notifications = format_ide_notification_content(result) @@ -200,7 +201,7 @@ def test_format_special_chars_escaped(self): """Test that special HTML characters are escaped in IDE tag content.""" text = 'File with & "characters" in path.' - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None notifications = format_ide_notification_content(result) @@ -220,7 +221,7 @@ def test_format_ide_diagnostics(self): "Question." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None notifications = format_ide_notification_content(result) @@ -239,7 +240,7 @@ def test_format_ide_selection_short(self): "Question." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None notifications = format_ide_notification_content(result) @@ -253,7 +254,7 @@ def test_format_ide_selection_long(self): long_selection = "The user selected lines 1 to 50:\n" + ("line content\n" * 30) text = f"{long_selection}\nQuestion." - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None notifications = format_ide_notification_content(result) @@ -273,7 +274,7 @@ def test_format_mixed_ide_tags(self): "Please review." ) - result = parse_ide_notifications(text) + result = create_ide_notification_content(text) assert result is not None notifications = format_ide_notification_content(result) @@ -285,12 +286,12 @@ def test_format_mixed_ide_tags(self): # ============================================================================= -# User Message Content Tests - parse_user_message_content() +# User Message Content Tests - create_user_message() # ============================================================================= class TestParseUserMessageContent: - """Tests for parse_user_message_content() function.""" + """Tests for create_user_message() function.""" def test_parse_user_message_with_multi_item_content(self): """Test parsing user message with multiple content items (text + image).""" @@ -312,7 +313,9 @@ def test_parse_user_message_with_multi_item_content(self): image_item, ] - content_model = parse_user_message_content(content_list) + content_model = create_user_message( + content_list, extract_text_content(content_list) + ) # Should return UserTextMessage with items assert isinstance(content_model, UserTextMessage) diff --git a/test/test_template_utils.py b/test/test_template_utils.py index 196510d2..74462096 100644 --- a/test/test_template_utils.py +++ b/test/test_template_utils.py @@ -4,7 +4,7 @@ import pytest from datetime import datetime from claude_code_log.parser import parse_timestamp, extract_text_content -from claude_code_log.html import parse_slash_command +from claude_code_log.factories import create_slash_command_message from claude_code_log.html import escape_html from claude_code_log.utils import format_timestamp from claude_code_log.models import TextContent, ToolUseContent, ToolResultContent @@ -97,41 +97,41 @@ def test_extract_text_content_no_text_items(self): class TestCommandExtraction: """Test command information extraction from system messages.""" - def test_parse_slash_command_complete(self): + def test_create_slash_command_message_complete(self): """Test parsing complete slash command information.""" text = 'Testing...\ntest-cmd\n--verbose\n{"type": "text", "text": "Test content"}' - result = parse_slash_command(text) + result = create_slash_command_message(text) assert result is not None assert result.command_name == "test-cmd" assert result.command_args == "--verbose" assert result.command_contents == "Test content" - def test_parse_slash_command_missing_parts(self): + def test_create_slash_command_message_missing_parts(self): """Test parsing slash command with missing parts.""" text = "minimal-cmd" - result = parse_slash_command(text) + result = create_slash_command_message(text) assert result is not None assert result.command_name == "minimal-cmd" assert result.command_args == "" assert result.command_contents == "" - def test_parse_slash_command_no_command(self): + def test_create_slash_command_message_no_command(self): """Test parsing text without command tags returns None.""" text = "This is just regular text with no command tags" - result = parse_slash_command(text) + result = create_slash_command_message(text) assert result is None # No command-name tag found - def test_parse_slash_command_malformed_json(self): + def test_create_slash_command_message_malformed_json(self): """Test parsing command contents with malformed JSON.""" text = 'bad-json\n{"invalid": json' - result = parse_slash_command(text) + result = create_slash_command_message(text) assert result is not None assert result.command_name == "bad-json" @@ -181,9 +181,9 @@ def test_extract_text_content_none(self): result = extract_text_content(None) assert result == "" - def test_parse_slash_command_empty_string(self): + def test_create_slash_command_message_empty_string(self): """Test parsing slash command from empty string returns None.""" - result = parse_slash_command("") + result = create_slash_command_message("") assert result is None # No command-name tag found diff --git a/test/test_user_renderer.py b/test/test_user_renderer.py index 602f4c8f..1c1721b1 100644 --- a/test/test_user_renderer.py +++ b/test/test_user_renderer.py @@ -1,8 +1,8 @@ """Tests for user message parsing and rendering. Split into: -- Parsing tests: test parse_compacted_summary(), parse_user_memory() -- Content model tests: test parse_user_message_content() +- Parsing tests: test create_compacted_summary_message(), create_user_memory_message() +- Content model tests: test create_user_message() - HTML rendering tests: test full pipeline from JSONL to HTML """ @@ -24,23 +24,24 @@ UserMemoryMessage, UserTextMessage, ) -from claude_code_log.user_parser import ( +from claude_code_log.factories import ( COMPACTED_SUMMARY_PREFIX, - parse_compacted_summary, - parse_user_memory, - parse_user_message_content, + create_compacted_summary_message, + create_user_memory_message, + create_user_message, ) +from claude_code_log.parser import extract_text_content # ============================================================================= -# Parsing Tests - parse_compacted_summary() +# Parsing Tests - create_compacted_summary_message() # ============================================================================= -class TestParseCompactedSummary: - """Tests for parse_compacted_summary() parser function (takes content list).""" +class TestCreateCompactedSummaryMessage: + """Tests for create_compacted_summary_message() factory function (takes content list).""" - def test_parse_compacted_summary_detected(self): + def test_create_compacted_summary_message_detected(self): """Test that compacted summary is detected and content combined.""" text = ( f"{COMPACTED_SUMMARY_PREFIX}. The conversation is summarized below:\n" @@ -48,27 +49,27 @@ def test_parse_compacted_summary_detected(self): ) content_list = [TextContent(type="text", text=text)] - result = parse_compacted_summary(content_list) + result = create_compacted_summary_message(content_list) assert result is not None assert isinstance(result, CompactedSummaryMessage) assert result.summary_text == text - def test_parse_compacted_summary_not_detected(self): + def test_create_compacted_summary_message_not_detected(self): """Test that regular text is not detected as compacted summary.""" text = "This is a regular user message." content_list = [TextContent(type="text", text=text)] - result = parse_compacted_summary(content_list) + result = create_compacted_summary_message(content_list) assert result is None - def test_parse_compacted_summary_empty_list(self): + def test_create_compacted_summary_message_empty_list(self): """Test that empty content list returns None.""" - result = parse_compacted_summary([]) + result = create_compacted_summary_message([]) assert result is None - def test_parse_compacted_summary_combines_multiple_texts(self): + def test_create_compacted_summary_message_combines_multiple_texts(self): """Test that multiple text items are combined with double newlines.""" first_text = f"{COMPACTED_SUMMARY_PREFIX}. Part 1." second_text = "Part 2." @@ -79,7 +80,7 @@ def test_parse_compacted_summary_combines_multiple_texts(self): TextContent(type="text", text=third_text), ] - result = parse_compacted_summary(content_list) + result = create_compacted_summary_message(content_list) assert result is not None expected = "\n\n".join([first_text, second_text, third_text]) @@ -87,74 +88,76 @@ def test_parse_compacted_summary_combines_multiple_texts(self): # ============================================================================= -# Parsing Tests - parse_user_memory() +# Parsing Tests - create_user_memory_message() # ============================================================================= class TestParseUserMemory: - """Tests for parse_user_memory() parser function.""" + """Tests for create_user_memory_message() parser function.""" - def test_parse_user_memory_detected(self): + def test_create_user_memory_message_detected(self): """Test that user memory input tag is detected correctly.""" text = "Memory content from CLAUDE.md" - result = parse_user_memory(text) + result = create_user_memory_message(text) assert result is not None assert isinstance(result, UserMemoryMessage) assert result.memory_text == "Memory content from CLAUDE.md" - def test_parse_user_memory_with_surrounding_text(self): + def test_create_user_memory_message_with_surrounding_text(self): """Test memory tag extraction from mixed content.""" text = "Some prefix The actual memory suffix" - result = parse_user_memory(text) + result = create_user_memory_message(text) assert result is not None assert result.memory_text == "The actual memory" - def test_parse_user_memory_multiline(self): + def test_create_user_memory_message_multiline(self): """Test multiline memory content.""" memory_content = "Line 1\nLine 2\nLine 3" text = f"{memory_content}" - result = parse_user_memory(text) + result = create_user_memory_message(text) assert result is not None assert result.memory_text == memory_content - def test_parse_user_memory_not_detected(self): + def test_create_user_memory_message_not_detected(self): """Test that regular text without tag returns None.""" text = "Regular text without memory tag." - result = parse_user_memory(text) + result = create_user_memory_message(text) assert result is None - def test_parse_user_memory_strips_whitespace(self): + def test_create_user_memory_message_strips_whitespace(self): """Test that memory content whitespace is stripped.""" text = " \n Content with spaces \n " - result = parse_user_memory(text) + result = create_user_memory_message(text) assert result is not None assert result.memory_text == "Content with spaces" # ============================================================================= -# Content Model Tests - parse_user_message_content() +# Content Model Tests - create_user_message() # ============================================================================= class TestParseUserMessageContentCompacted: - """Tests for parse_user_message_content() handling compacted summaries.""" + """Tests for create_user_message() handling compacted summaries.""" def test_compacted_summary_single_text_item(self): """Test compacted summary with single text content item.""" text = f"{COMPACTED_SUMMARY_PREFIX}. The conversation summary." content_list = [TextContent(type="text", text=text)] - content_model = parse_user_message_content(content_list) + content_model = create_user_message( + content_list, extract_text_content(content_list) + ) assert content_model is not None assert isinstance(content_model, CompactedSummaryMessage) @@ -171,7 +174,9 @@ def test_compacted_summary_multiple_text_items(self): TextContent(type="text", text=third_text), ] - content_model = parse_user_message_content(content_list) + content_model = create_user_message( + content_list, extract_text_content(content_list) + ) assert content_model is not None assert isinstance(content_model, CompactedSummaryMessage) @@ -181,14 +186,16 @@ def test_compacted_summary_multiple_text_items(self): class TestParseUserMessageContentMemory: - """Tests for parse_user_message_content() handling user memory.""" + """Tests for create_user_message() handling user memory.""" def test_user_memory_detected(self): """Test user memory content is detected and returned.""" text = "CLAUDE.md content here" content_list = [TextContent(type="text", text=text)] - content_model = parse_user_message_content(content_list) + content_model = create_user_message( + content_list, extract_text_content(content_list) + ) assert content_model is not None assert isinstance(content_model, UserMemoryMessage) @@ -196,14 +203,16 @@ def test_user_memory_detected(self): class TestParseUserMessageContentRegular: - """Tests for parse_user_message_content() handling regular user text.""" + """Tests for create_user_message() handling regular user text.""" def test_regular_text(self): """Test regular user text without special markers.""" text = "Hello, please help me with this code." content_list = [TextContent(type="text", text=text)] - content_model = parse_user_message_content(content_list) + content_model = create_user_message( + content_list, extract_text_content(content_list) + ) assert content_model is not None assert isinstance(content_model, UserTextMessage) @@ -215,7 +224,9 @@ def test_empty_content_list(self): """Test empty content list returns None.""" content_list = [] - content_model = parse_user_message_content(content_list) + content_model = create_user_message( + content_list, extract_text_content(content_list) + ) assert content_model is None diff --git a/test/test_utils.py b/test/test_utils.py index 41fab9ba..ebfb6d4f 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -2,12 +2,12 @@ """Test cases for the utils module functions.""" import pytest -from claude_code_log.factories import is_system_message -from claude_code_log.user_parser import ( +from claude_code_log.factories import ( is_bash_input, is_bash_output, is_command_message, is_local_command_output, + is_system_message, ) from claude_code_log.utils import ( should_skip_message, From 46c51ea8b01c566089b63fdc65c688666485fcf7 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 19:45:26 +0100 Subject: [PATCH 19/57] Move assistant_parser.py and tool_parser.py to factories/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create factories/assistant_factory.py with create_assistant_message, create_thinking_message (renamed from parse_*) - Create factories/tool_factory.py with create_tool_input, create_tool_use_message, create_tool_result_message (renamed from parse_*) - Add meta parameter to all message creation functions - Update imports in renderer.py and factories/__init__.py - Delete old assistant_parser.py and tool_parser.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/__init__.py | 26 ++++++++++++ .../assistant_factory.py} | 27 +++++++----- .../tool_factory.py} | 41 +++++++++++-------- claude_code_log/factories/user_factory.py | 9 ++-- claude_code_log/renderer.py | 33 ++++++++------- 5 files changed, 87 insertions(+), 49 deletions(-) rename claude_code_log/{assistant_parser.py => factories/assistant_factory.py} (71%) rename claude_code_log/{tool_parser.py => factories/tool_factory.py} (92%) diff --git a/claude_code_log/factories/__init__.py b/claude_code_log/factories/__init__.py index 2469c66f..108a9c91 100644 --- a/claude_code_log/factories/__init__.py +++ b/claude_code_log/factories/__init__.py @@ -31,6 +31,21 @@ IDE_OPENED_FILE_PATTERN, IDE_SELECTION_PATTERN, ) +from .assistant_factory import ( + # Assistant message creation + create_assistant_message, + create_thinking_message, +) +from .tool_factory import ( + # Tool message creation + create_tool_input, + create_tool_use_message, + create_tool_result_message, + # Tool processing result + ToolItemResult, + # Tool input models mapping + TOOL_INPUT_MODELS, +) from .transcript_factory import ( # Content type constants ASSISTANT_CONTENT_TYPES, @@ -86,4 +101,15 @@ "IDE_DIAGNOSTICS_PATTERN", "IDE_OPENED_FILE_PATTERN", "IDE_SELECTION_PATTERN", + # Assistant message creation + "create_assistant_message", + "create_thinking_message", + # Tool message creation + "create_tool_input", + "create_tool_use_message", + "create_tool_result_message", + # Tool processing result + "ToolItemResult", + # Tool input models mapping + "TOOL_INPUT_MODELS", ] diff --git a/claude_code_log/assistant_parser.py b/claude_code_log/factories/assistant_factory.py similarity index 71% rename from claude_code_log/assistant_parser.py rename to claude_code_log/factories/assistant_factory.py index ea182b6f..56e1bfb1 100644 --- a/claude_code_log/assistant_parser.py +++ b/claude_code_log/factories/assistant_factory.py @@ -1,34 +1,38 @@ -"""Parser for assistant transcript entries. +"""Factory for assistant transcript entries. -This module handles parsing of AssistantTranscriptEntry content into MessageContent subclasses: +This module handles creation of AssistantTranscriptEntry content into MessageContent +subclasses: - AssistantTextMessage: Claude's text responses - ThinkingMessage: Extended thinking blocks """ from typing import Optional -from .models import ( +from ..models import ( AssistantTextMessage, ContentItem, + MessageMeta, ThinkingContent, ThinkingMessage, ) # ============================================================================= -# Message Parsing Functions +# Message Creation Functions # ============================================================================= -def parse_assistant_message_content( +def create_assistant_message( items: list[ContentItem], + meta: Optional[MessageMeta] = None, ) -> Optional[AssistantTextMessage]: - """Parse assistant message content into AssistantTextMessage. + """Create AssistantTextMessage from content items. Creates AssistantTextMessage from text/image content items. Args: items: List of text/image content items (no tool_use, tool_result, thinking). + meta: Optional message metadata. Returns: AssistantTextMessage if items is non-empty, None otherwise. @@ -37,18 +41,21 @@ def parse_assistant_message_content( # (empty text already filtered by chunk_message_content) if items: return AssistantTextMessage( - items=items # type: ignore[arg-type] + items=items, # type: ignore[arg-type] + meta=meta, ) return None -def parse_thinking_item( +def create_thinking_message( tool_item: ContentItem, + meta: Optional[MessageMeta] = None, ) -> ThinkingMessage: - """Parse a thinking content item into ThinkingMessage. + """Create ThinkingMessage from a thinking content item. Args: tool_item: ThinkingContent or compatible object with 'thinking' attribute + meta: Optional message metadata. Returns: ThinkingMessage containing the thinking text and optional signature. @@ -62,4 +69,4 @@ def parse_thinking_item( signature = None # Create the content model (formatting happens in HtmlRenderer) - return ThinkingMessage(thinking=thinking_text, signature=signature) + return ThinkingMessage(thinking=thinking_text, signature=signature, meta=meta) diff --git a/claude_code_log/tool_parser.py b/claude_code_log/factories/tool_factory.py similarity index 92% rename from claude_code_log/tool_parser.py rename to claude_code_log/factories/tool_factory.py index 0c38262e..30ba53c1 100644 --- a/claude_code_log/tool_parser.py +++ b/claude_code_log/factories/tool_factory.py @@ -1,13 +1,13 @@ -"""Parser for tool use and tool result content. +"""Factory for tool use and tool result content. -This module handles parsing of tool-related content into MessageContent subclasses: +This module handles creation of tool-related content into MessageContent subclasses: - ToolUseMessage: Tool invocations with typed inputs (BashInput, ReadInput, etc.) - ToolResultMessage: Tool results with output and context -Also provides parsing of tool inputs into typed models: -- parse_tool_input(): Parse raw tool input dict into typed model -- parse_tool_use_item(): Process ToolUseContent into ToolUseMessage -- parse_tool_result_item(): Process ToolResultContent into ToolResultMessage +Also provides creation of tool inputs into typed models: +- create_tool_input(): Create typed tool input from raw dict +- create_tool_use_message(): Process ToolUseContent into ToolItemResult +- create_tool_result_message(): Process ToolResultContent into ToolItemResult """ from dataclasses import dataclass @@ -15,7 +15,7 @@ from pydantic import BaseModel -from .models import ( +from ..models import ( # Tool input models AskUserQuestionInput, AskUserQuestionItem, @@ -28,6 +28,7 @@ GlobInput, GrepInput, MessageContent, + MessageMeta, MultiEditInput, ReadInput, TaskInput, @@ -40,7 +41,7 @@ ToolUseMessage, WriteInput, ) -from .html import escape_html, format_tool_use_title +from ..html import escape_html, format_tool_use_title # ============================================================================= @@ -207,12 +208,14 @@ def _parse_exitplanmode_lenient(data: dict[str, Any]) -> ExitPlanModeInput: # ============================================================================= -# Tool Input Parsing +# Tool Input Creation # ============================================================================= -def parse_tool_input(tool_name: str, input_data: dict[str, Any]) -> Optional[ToolInput]: - """Parse tool input dictionary into a typed model. +def create_tool_input( + tool_name: str, input_data: dict[str, Any] +) -> Optional[ToolInput]: + """Create typed tool input from raw dictionary. Uses strict validation first, then lenient parsing if available. @@ -256,15 +259,17 @@ class ToolItemResult: is_error: bool = False # For tool_result error state -def parse_tool_use_item( +def create_tool_use_message( tool_item: ContentItem, tool_use_context: dict[str, ToolUseContent], + meta: Optional[MessageMeta] = None, ) -> Optional[ToolItemResult]: - """Process a tool_use content item. + """Create ToolItemResult from 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 + meta: Optional message metadata Returns: ToolItemResult with tool_use content model, or None if item should be skipped @@ -281,7 +286,7 @@ def parse_tool_use_item( tool_use = tool_item # Parse tool input once, use for both title and message content - parsed = parse_tool_input(tool_use.name, tool_use.input) + parsed = create_tool_input(tool_use.name, tool_use.input) # Title is computed here but content formatting happens in HtmlRenderer tool_message_title = format_tool_use_title(tool_use.name, parsed) @@ -298,6 +303,7 @@ def parse_tool_use_item( input=parsed if parsed is not None else tool_use, tool_use_id=tool_use.id, tool_name=tool_use.name, + meta=meta, ) return ToolItemResult( @@ -309,15 +315,17 @@ def parse_tool_use_item( ) -def parse_tool_result_item( +def create_tool_result_message( tool_item: ContentItem, tool_use_context: dict[str, ToolUseContent], + meta: Optional[MessageMeta] = None, ) -> Optional[ToolItemResult]: - """Process a tool_result content item. + """Create ToolItemResult from a tool_result content item. Args: tool_item: The tool result content item tool_use_context: Dict with tool_use_id -> ToolUseContent mapping + meta: Optional message metadata Returns: ToolItemResult with tool_result content model, or None if item should be skipped @@ -354,6 +362,7 @@ def parse_tool_result_item( is_error=tool_result.is_error or False, tool_name=result_tool_name, file_path=result_file_path, + meta=meta, ) # Retroactive deduplication: if Task result, extract content for later matching diff --git a/claude_code_log/factories/user_factory.py b/claude_code_log/factories/user_factory.py index 4228097c..bdd3a90f 100644 --- a/claude_code_log/factories/user_factory.py +++ b/claude_code_log/factories/user_factory.py @@ -436,13 +436,11 @@ def create_user_message( first_text = getattr(first_text_item, "text", "") if first_text_item else "" # Check for compacted session summary first (handles text combining internally) - compacted = create_compacted_summary_message(content_list, meta=meta) - if compacted: + if compacted := create_compacted_summary_message(content_list, meta=meta): return compacted # Check for user memory input - user_memory = create_user_memory_message(first_text, meta=meta) - if user_memory: + if user_memory := create_user_memory_message(first_text, meta=meta): return user_memory # Build items list preserving order, extracting IDE notifications from text @@ -452,9 +450,8 @@ def create_user_message( # Check for text content if hasattr(item, "text"): item_text: str = getattr(item, "text") # type: ignore[assignment] - ide_content = create_ide_notification_content(item_text) - if ide_content: + if ide_content := create_ide_notification_content(item_text): # Add IDE notification item first items.append(ide_content) remaining_text: str = ide_content.remaining_text diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 9146bf62..22884031 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -42,12 +42,13 @@ from .factories import ( as_assistant_entry, as_user_entry, + create_assistant_message, create_meta, + create_thinking_message, + create_tool_result_message, + create_tool_use_message, create_user_message, -) -from .assistant_parser import ( - parse_assistant_message_content, - parse_thinking_item, + ToolItemResult, ) from .utils import ( format_timestamp, @@ -61,12 +62,6 @@ log_timing, ) -from .tool_parser import ( - ToolItemResult, - parse_tool_use_item, - parse_tool_result_item, -) - # -- Content Formatters ------------------------------------------------------- # NOTE: Content formatters have been moved to html/ submodules: @@ -618,9 +613,9 @@ def prepare_session_navigation( # -- Message Processing Functions --------------------------------------------- -# Note: Message parsing functions have been moved to dedicated modules: -# - user_parser.py: parse_user_message_content, parse_slash_command, etc. -# - assistant_parser.py: parse_assistant_message_content, parse_thinking_item +# Note: Message creation functions have been moved to the factories package: +# - factories/user_factory.py: create_user_message, create_slash_command_message, etc. +# - factories/assistant_factory.py: create_assistant_message, create_thinking_message # - factories/system_factory.py: create_system_message @@ -1785,7 +1780,7 @@ def _render_messages( meta=meta, ) elif effective_type == "assistant": - content_model = parse_assistant_message_content(chunk) + content_model = create_assistant_message(chunk, meta=meta) # Convert to UserSteeringMessage for queue-operation 'remove' messages if ( @@ -1868,14 +1863,18 @@ def _render_messages( # Dispatch to appropriate handler based on item type tool_result: Optional[ToolItemResult] = None if isinstance(tool_item, ToolUseContent) or item_type == "tool_use": - tool_result = parse_tool_use_item(tool_item, tool_use_context) + tool_result = create_tool_use_message( + tool_item, tool_use_context, meta=meta + ) elif ( isinstance(tool_item, ToolResultContent) or item_type == "tool_result" ): - tool_result = parse_tool_result_item(tool_item, tool_use_context) + tool_result = create_tool_result_message( + tool_item, tool_use_context, meta=meta + ) elif isinstance(tool_item, ThinkingContent) or item_type == "thinking": - content = parse_thinking_item(tool_item) + content = create_thinking_message(tool_item, meta=meta) tool_result = ToolItemResult( message_type=content.message_type, message_title=content.message_title() or "Thinking", From e2bea7db03f3d2449bee0f2842d90c968ca01512 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 21:55:03 +0100 Subject: [PATCH 20/57] Make meta required (first positional) in MessageContent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add MessageMeta.empty() class method for placeholder instances - Change MessageContent.meta from Optional[MessageMeta] to MessageMeta (required) - Fix IdeNotificationContent to not inherit from MessageContent (it's embedded) - Update all creation sites to pass meta as first positional argument - Update tests to use MessageMeta.empty() where needed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 28 +++++++++++++++++++--------- claude_code_log/renderer.py | 7 ++++++- test/test_ide_tags.py | 4 +++- test/test_todowrite_rendering.py | 3 +++ test/test_user_renderer.py | 23 +++++++++++++++++------ 5 files changed, 48 insertions(+), 17 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 75e35ad1..1d63e559 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -73,6 +73,15 @@ class MessageMeta: cwd: str = "" git_branch: Optional[str] = None + @classmethod + def empty(cls, uuid: str = "") -> "MessageMeta": + """Create a placeholder MessageMeta with empty/default values. + + Useful for cases where full metadata isn't available at creation time + (e.g., SummaryTranscriptEntry where session_id is matched later). + """ + return cls(session_id="", timestamp="", uuid=uuid) + # ============================================================================= # Message Content Models @@ -89,14 +98,12 @@ class MessageContent: Subclasses represent specific content types that renderers can format appropriately for their output format. - The `meta` field is keyword-only with a default of None, allowing: - - Subclasses to have positional fields before it - - Progressive migration: call sites can pass meta=... when available - - Backward compatibility: parsing functions that don't have transcript access - can omit meta (the renderer can set it later if needed) + The `meta` field is required and first positional, ensuring all message + content always has associated metadata. Use MessageMeta.empty() when + full metadata isn't available at creation time. """ - meta: Optional[MessageMeta] = field(default=None, kw_only=True) + meta: MessageMeta def message_title(self) -> Optional[str]: """Return a title for this message content, or None for default behavior. @@ -340,10 +347,13 @@ class IdeDiagnostic: @dataclass -class IdeNotificationContent(MessageContent): - """Content for IDE notification tags. +class IdeNotificationContent: + """Content for IDE notification tags (embedded within user messages). + + This is NOT a MessageContent subclass - it's used as an item within + UserTextMessage.items alongside TextContent and ImageContent. - These are user messages containing IDE notification tags like: + Represents IDE notification tags like: - : File open notifications - : Code selection notifications - : Diagnostic JSON arrays diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 22884031..9fbb3656 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -13,6 +13,7 @@ from .models import ( BaseTranscriptEntry, + MessageMeta, MessageType, TranscriptEntry, AssistantTranscriptEntry, @@ -1371,6 +1372,7 @@ def _reorder_sidechain_template_messages( ): # Replace with note pointing to the Task result sidechain_msg.content = DedupNoticeMessage( + MessageMeta.empty(), notice_text="Task summary — see result above", target_uuid=message.uuid, original_text=sidechain_text, @@ -1717,6 +1719,7 @@ def _render_messages( message_id=None, ancestry=[], content=SessionHeaderMessage( + MessageMeta.empty(), title=session_title, session_id=session_id, summary=current_session_summary, @@ -1884,7 +1887,9 @@ def _render_messages( # Handle unknown content types tool_result = ToolItemResult( message_type="unknown", - content=UnknownMessage(type_name=str(type(tool_item))), + content=UnknownMessage( + meta or MessageMeta.empty(), type_name=str(type(tool_item)) + ), message_title="Unknown Content", ) diff --git a/test/test_ide_tags.py b/test/test_ide_tags.py index bb7034b7..57261022 100644 --- a/test/test_ide_tags.py +++ b/test/test_ide_tags.py @@ -22,6 +22,7 @@ IdeNotificationContent, ImageContent, ImageSource, + MessageMeta, TextContent, UserTextMessage, ) @@ -356,7 +357,8 @@ def test_format_user_text_content(self): def test_format_assistant_text_content(self): """Test that assistant text is formatted as markdown.""" content = AssistantTextMessage( - items=[TextContent(type="text", text="**Bold** response")] + MessageMeta.empty(), + items=[TextContent(type="text", text="**Bold** response")], ) html = format_assistant_text_content(content) diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index a4842818..56262cc9 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -9,6 +9,7 @@ from claude_code_log.html import format_todowrite_content, format_tool_use_content from claude_code_log.models import ( EditInput, + MessageMeta, TodoWriteInput, TodoWriteItem, ToolUseMessage, @@ -200,6 +201,7 @@ def test_todowrite_vs_regular_tool_use(self): * 3 ) regular_tool = ToolUseMessage( + MessageMeta.empty(), input=EditInput( file_path="/tmp/test.py", old_string="", @@ -211,6 +213,7 @@ def test_todowrite_vs_regular_tool_use(self): # Create TodoWrite tool use todowrite_tool = ToolUseMessage( + MessageMeta.empty(), input=TodoWriteInput( todos=[ TodoWriteItem( diff --git a/test/test_user_renderer.py b/test/test_user_renderer.py index 1c1721b1..b0ffd801 100644 --- a/test/test_user_renderer.py +++ b/test/test_user_renderer.py @@ -20,6 +20,7 @@ ) from claude_code_log.models import ( CompactedSummaryMessage, + MessageMeta, TextContent, UserMemoryMessage, UserTextMessage, @@ -241,7 +242,9 @@ class TestFormatCompactedSummaryMessage: def test_format_compacted_summary_basic(self): """Test basic compacted summary formatting.""" - content = CompactedSummaryMessage(summary_text="Summary:\n- Point 1\n- Point 2") + content = CompactedSummaryMessage( + MessageMeta.empty(), summary_text="Summary:\n- Point 1\n- Point 2" + ) html = format_compacted_summary_content(content) @@ -254,7 +257,9 @@ def test_format_compacted_summary_collapsible(self): """Test that long compacted summaries are collapsible.""" # Create long content that exceeds threshold long_summary = "Summary:\n" + "\n".join([f"- Point {i}" for i in range(50)]) - content = CompactedSummaryMessage(summary_text=long_summary) + content = CompactedSummaryMessage( + MessageMeta.empty(), summary_text=long_summary + ) html = format_compacted_summary_content(content) @@ -273,7 +278,9 @@ class TestFormatUserMemoryMessage: def test_format_user_memory_basic(self): """Test basic user memory formatting.""" - content = UserMemoryMessage(memory_text="CLAUDE.md content") + content = UserMemoryMessage( + MessageMeta.empty(), memory_text="CLAUDE.md content" + ) html = format_user_memory_content(content) @@ -283,7 +290,9 @@ def test_format_user_memory_basic(self): def test_format_user_memory_escapes_html(self): """Test that HTML characters are escaped.""" - content = UserMemoryMessage(memory_text="") + content = UserMemoryMessage( + MessageMeta.empty(), memory_text="" + ) html = format_user_memory_content(content) @@ -302,7 +311,8 @@ class TestFormatUserTextModelContent: def test_format_user_text_basic(self): """Test basic user text formatting.""" content = UserTextMessage( - items=[TextContent(type="text", text="User question here")] + MessageMeta.empty(), + items=[TextContent(type="text", text="User question here")], ) html = format_user_text_model_content(content) @@ -313,7 +323,8 @@ def test_format_user_text_basic(self): def test_format_user_text_escapes_html(self): """Test that HTML characters are escaped.""" content = UserTextMessage( - items=[TextContent(type="text", text='Test bold & "quotes"')] + MessageMeta.empty(), + items=[TextContent(type="text", text='Test bold & "quotes"')], ) html = format_user_text_model_content(content) From 3482d9c2334b5a4330edc4cf3f75f0f47f32d818 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 21 Dec 2025 23:17:29 +0100 Subject: [PATCH 21/57] Simplify TemplateMessage with content-derived properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add message_type property to 7 MessageContent subclasses that were missing it (SystemMessage, HookSummaryMessage, ToolResultMessage, ToolUseMessage, UnknownMessage, SessionHeaderMessage, DedupNoticeMessage) - Add has_markdown property to MessageContent base (False) with overrides in AssistantTextMessage, ThinkingMessage, CompactedSummaryMessage (True) - Add meta shortcut field to TemplateMessage for easy transition - Convert is_session_header and has_markdown from parameters to derived properties - Update template to use is_session_header() helper and content.has_markdown - Remove dead session_subtitle code from template - Make meta required as first positional parameter in all factory functions - Update tests to pass MessageMeta.empty() as first argument 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../factories/assistant_factory.py | 12 +-- claude_code_log/factories/tool_factory.py | 8 +- claude_code_log/factories/user_factory.py | 26 ++--- claude_code_log/html/__init__.py | 2 + claude_code_log/html/renderer.py | 8 +- .../html/templates/transcript.html | 8 +- claude_code_log/html/utils.py | 12 +++ claude_code_log/models.py | 48 +++++++++ claude_code_log/renderer.py | 57 +++++------ dev-docs/MESSAGE_REFACTORING2.md | 70 +++++++++---- dev-docs/TEMPLATE_MESSAGE_REFACTORING.md | 99 +++++++++++++++++++ dev-docs/messages.md | 14 ++- test/__snapshots__/test_snapshot_html.ambr | 6 -- test/test_ide_tags.py | 2 +- test/test_template_utils.py | 17 ++-- test/test_user_renderer.py | 31 +++--- 16 files changed, 305 insertions(+), 115 deletions(-) create mode 100644 dev-docs/TEMPLATE_MESSAGE_REFACTORING.md diff --git a/claude_code_log/factories/assistant_factory.py b/claude_code_log/factories/assistant_factory.py index 56e1bfb1..c2db4531 100644 --- a/claude_code_log/factories/assistant_factory.py +++ b/claude_code_log/factories/assistant_factory.py @@ -23,16 +23,16 @@ def create_assistant_message( + meta: MessageMeta, items: list[ContentItem], - meta: Optional[MessageMeta] = None, ) -> Optional[AssistantTextMessage]: """Create AssistantTextMessage from content items. Creates AssistantTextMessage from text/image content items. Args: + meta: Message metadata. items: List of text/image content items (no tool_use, tool_result, thinking). - meta: Optional message metadata. Returns: AssistantTextMessage if items is non-empty, None otherwise. @@ -41,21 +41,21 @@ def create_assistant_message( # (empty text already filtered by chunk_message_content) if items: return AssistantTextMessage( + meta, items=items, # type: ignore[arg-type] - meta=meta, ) return None def create_thinking_message( + meta: MessageMeta, tool_item: ContentItem, - meta: Optional[MessageMeta] = None, ) -> ThinkingMessage: """Create ThinkingMessage from a thinking content item. Args: + meta: Message metadata. tool_item: ThinkingContent or compatible object with 'thinking' attribute - meta: Optional message metadata. Returns: ThinkingMessage containing the thinking text and optional signature. @@ -69,4 +69,4 @@ def create_thinking_message( signature = None # Create the content model (formatting happens in HtmlRenderer) - return ThinkingMessage(thinking=thinking_text, signature=signature, meta=meta) + return ThinkingMessage(meta, thinking=thinking_text, signature=signature) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 30ba53c1..7cb54da0 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -260,9 +260,9 @@ class ToolItemResult: def create_tool_use_message( + meta: MessageMeta, tool_item: ContentItem, tool_use_context: dict[str, ToolUseContent], - meta: Optional[MessageMeta] = None, ) -> Optional[ToolItemResult]: """Create ToolItemResult from a tool_use content item. @@ -300,10 +300,10 @@ def create_tool_use_message( # Create ToolUseMessage wrapper with parsed input for specialized formatting # Use ToolUseContent as fallback when no specialized parser exists tool_use_message = ToolUseMessage( + meta, input=parsed if parsed is not None else tool_use, tool_use_id=tool_use.id, tool_name=tool_use.name, - meta=meta, ) return ToolItemResult( @@ -316,9 +316,9 @@ def create_tool_use_message( def create_tool_result_message( + meta: MessageMeta, tool_item: ContentItem, tool_use_context: dict[str, ToolUseContent], - meta: Optional[MessageMeta] = None, ) -> Optional[ToolItemResult]: """Create ToolItemResult from a tool_result content item. @@ -357,12 +357,12 @@ def create_tool_result_message( # Pass the whole ToolResultContent as output (generic fallback) # TODO: Parse into specialized output types (ReadOutput, EditOutput) when appropriate content_model = ToolResultMessage( + meta, tool_use_id=tool_result.tool_use_id, output=tool_result, # ToolResultContent as ToolOutput is_error=tool_result.is_error or False, tool_name=result_tool_name, file_path=result_file_path, - meta=meta, ) # Retroactive deduplication: if Task result, extract content for later matching diff --git a/claude_code_log/factories/user_factory.py b/claude_code_log/factories/user_factory.py index bdd3a90f..4f63842f 100644 --- a/claude_code_log/factories/user_factory.py +++ b/claude_code_log/factories/user_factory.py @@ -73,8 +73,8 @@ def is_bash_output(text_content: str) -> bool: def create_slash_command_message( + meta: MessageMeta, text: str, - meta: Optional[MessageMeta] = None, ) -> Optional[SlashCommandMessage]: """Create SlashCommandMessage from text containing command tags. @@ -122,8 +122,8 @@ def create_slash_command_message( def create_command_output_message( + meta: MessageMeta, text: str, - meta: Optional[MessageMeta] = None, ) -> Optional[CommandOutputMessage]: """Create CommandOutputMessage from text containing local-command-stdout tags. @@ -157,8 +157,8 @@ def create_command_output_message( def create_bash_input_message( + meta: MessageMeta, text: str, - meta: Optional[MessageMeta] = None, ) -> Optional[BashInputMessage]: """Create BashInputMessage from text containing bash-input tags. @@ -177,8 +177,8 @@ def create_bash_input_message( def create_bash_output_message( + meta: MessageMeta, text: str, - meta: Optional[MessageMeta] = None, ) -> Optional[BashOutputMessage]: """Create BashOutputMessage from text containing bash-stdout/bash-stderr tags. @@ -296,8 +296,8 @@ def create_ide_notification_content(text: str) -> Optional[IdeNotificationConten def create_compacted_summary_message( + meta: MessageMeta, content_list: list[ContentItem], - meta: Optional[MessageMeta] = None, ) -> Optional[CompactedSummaryMessage]: """Create CompactedSummaryMessage from content list. @@ -338,8 +338,8 @@ def create_compacted_summary_message( def create_user_memory_message( + meta: MessageMeta, text: str, - meta: Optional[MessageMeta] = None, ) -> Optional[UserMemoryMessage]: """Create UserMemoryMessage from text containing user-memory-input tag. @@ -378,10 +378,10 @@ def create_user_memory_message( def create_user_message( + meta: MessageMeta, content_list: list[ContentItem], text_content: str, is_slash_command: bool = False, - meta: Optional[MessageMeta] = None, ) -> Optional[UserMessageContent]: """Create a user message content model from content items. @@ -410,16 +410,16 @@ def create_user_message( # Check for special message patterns first (before generic parsing) if is_command_message(text_content): - return create_slash_command_message(text_content, meta=meta) + return create_slash_command_message(meta, text_content) if is_local_command_output(text_content): - return create_command_output_message(text_content, meta=meta) + return create_command_output_message(meta, text_content) if is_bash_input(text_content): - return create_bash_input_message(text_content, meta=meta) + return create_bash_input_message(meta, text_content) if is_bash_output(text_content): - return create_bash_output_message(text_content, meta=meta) + return create_bash_output_message(meta, text_content) # Slash command expanded prompts - combine all text as markdown if is_slash_command: @@ -436,11 +436,11 @@ def create_user_message( first_text = getattr(first_text_item, "text", "") if first_text_item else "" # Check for compacted session summary first (handles text combining internally) - if compacted := create_compacted_summary_message(content_list, meta=meta): + if compacted := create_compacted_summary_message(meta, content_list): return compacted # Check for user memory input - if user_memory := create_user_memory_message(first_text, meta=meta): + if user_memory := create_user_memory_message(meta, first_text): return user_memory # Build items list preserving order, extracting IDE notifications from text diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py index 8b25e352..615ec10b 100644 --- a/claude_code_log/html/__init__.py +++ b/claude_code_log/html/__init__.py @@ -8,6 +8,7 @@ escape_html, get_message_emoji, get_template_environment, + is_session_header, render_collapsible_code, render_file_content_collapsible, render_markdown, @@ -89,6 +90,7 @@ "escape_html", "get_message_emoji", "get_template_environment", + "is_session_header", "render_collapsible_code", "render_file_content_collapsible", "render_markdown", diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 73f13123..b3040f16 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -62,7 +62,12 @@ 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 +from .utils import ( + css_class_from_message, + get_message_emoji, + get_template_environment, + is_session_header, +) if TYPE_CHECKING: from ..cache import CacheManager @@ -236,6 +241,7 @@ def generate( library_version=get_library_version(), css_class_from_message=css_class_from_message, get_message_emoji=get_message_emoji, + is_session_header=is_session_header, ) ) diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 2efac95c..b4d23536 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -70,14 +70,10 @@

🔍 Search & Filter

{% endif %} {% for message, html_content, formatted_timestamp in messages %} - {% if message.is_session_header %} + {% if is_session_header(message) %}
Session: {{ html_content }}
- {% if message.session_subtitle %} -
{{ - message.session_subtitle }} ({{message.session_id}})
- {% endif %} {% if message.has_children %}
{% if message.immediate_children_count == message.total_descendants_count %} @@ -102,7 +98,7 @@

🔍 Search & Filter

{% else %} {%- set msg_css_class = css_class_from_message(message) %} - {% set markdown = message.has_markdown %} + {% set markdown = message.content.has_markdown if message.content else false %}
{% set msg_emoji = get_message_emoji(message) -%} diff --git a/claude_code_log/html/utils.py b/claude_code_log/html/utils.py index 0cf90394..8822ab3e 100644 --- a/claude_code_log/html/utils.py +++ b/claude_code_log/html/utils.py @@ -134,6 +134,18 @@ def css_class_from_message(msg: "TemplateMessage") -> str: return " ".join(parts) +def is_session_header(msg: "TemplateMessage") -> bool: + """Check if message is a session header. + + Args: + msg: The template message to check + + Returns: + True if message content is a SessionHeaderMessage + """ + return isinstance(msg.content, SessionHeaderMessage) + + def get_message_emoji(msg: "TemplateMessage") -> str: """Return appropriate emoji for message type. diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 1d63e559..cbe7c075 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -113,6 +113,14 @@ def message_title(self) -> Optional[str]: """ return None + @property + def has_markdown(self) -> bool: + """Whether this content should be rendered as markdown. + + Subclasses that contain markdown content should override to return True. + """ + return False + @dataclass class SystemMessage(MessageContent): @@ -124,6 +132,10 @@ class SystemMessage(MessageContent): level: str # "info", "warning", "error" text: str # Raw text content (may contain ANSI codes) + @property + def message_type(self) -> str: + return "system" + def message_title(self) -> Optional[str]: """Return 'System Info', 'System Warning', or 'System Error'.""" return f"System {self.level.title()}" @@ -148,6 +160,10 @@ class HookSummaryMessage(MessageContent): hook_errors: list[str] # Error messages from hooks hook_infos: list[HookInfo] # Info about each hook executed + @property + def message_type(self) -> str: + return "system" + def message_title(self) -> Optional[str]: """Return 'System Hook' for hook summary messages.""" return "System Hook" @@ -249,6 +265,10 @@ class ToolResultMessage(MessageContent): 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 + @property + def message_type(self) -> str: + return "tool_result" + @dataclass class ToolUseMessage(MessageContent): @@ -262,6 +282,10 @@ class ToolUseMessage(MessageContent): tool_use_id: str # From ToolUseContent.id tool_name: str # From ToolUseContent.name + @property + def message_type(self) -> str: + return "tool_use" + @dataclass class CompactedSummaryMessage(MessageContent): @@ -279,6 +303,10 @@ class CompactedSummaryMessage(MessageContent): def message_type(self) -> str: return "user" + @property + def has_markdown(self) -> bool: + return True + def message_title(self) -> Optional[str]: return "User (compacted conversation)" @@ -467,6 +495,10 @@ class AssistantTextMessage(MessageContent): def message_type(self) -> str: return "assistant" + @property + def has_markdown(self) -> bool: + return True + def message_title(self) -> Optional[str]: return "Assistant" @@ -489,6 +521,10 @@ class ThinkingMessage(MessageContent): def message_type(self) -> str: return "thinking" + @property + def has_markdown(self) -> bool: + return True + def message_title(self) -> Optional[str]: return "Thinking" @@ -503,6 +539,10 @@ class UnknownMessage(MessageContent): type_name: str # The name/description of the unknown type + @property + def message_type(self) -> str: + return "unknown" + # ============================================================================= # Tool Output Models @@ -655,6 +695,10 @@ class SessionHeaderMessage(MessageContent): session_id: str summary: Optional[str] = None + @property + def message_type(self) -> str: + return "session_header" + @dataclass class DedupNoticeMessage(MessageContent): @@ -669,6 +713,10 @@ class DedupNoticeMessage(MessageContent): target_message_id: Optional[str] = None # Resolved message ID for anchor link original_text: Optional[str] = None # Original duplicated content (for debugging) + @property + def message_type(self) -> str: + return "dedup_notice" + # ============================================================================= # Tool Input Models diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 9fbb3656..fd98c312 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -25,11 +25,9 @@ ToolResultContent, ToolUseContent, ThinkingContent, - ThinkingMessage, # Structured content types AssistantTextMessage, CommandOutputMessage, - CompactedSummaryMessage, DedupNoticeMessage, SessionHeaderMessage, SlashCommandMessage, @@ -163,11 +161,9 @@ def __init__( raw_timestamp: Optional[str] = None, session_summary: Optional[str] = None, session_id: Optional[str] = None, - is_session_header: bool = False, token_usage: Optional[str] = None, tool_use_id: Optional[str] = None, title_hint: Optional[str] = None, - has_markdown: bool = False, message_title: Optional[str] = None, message_id: Optional[str] = None, ancestry: Optional[list[str]] = None, @@ -181,6 +177,8 @@ def __init__( self.type = message_type # Structured content for rendering self.content = content + # Shortcut to content.meta for transition period + self.meta = content.meta if content else None self.is_sidechain = is_sidechain self.raw_timestamp = raw_timestamp # Display title for message header (capitalized, with decorations) @@ -189,15 +187,12 @@ def __init__( ) self.session_summary = session_summary self.session_id = session_id - self.is_session_header = is_session_header - self.session_subtitle: Optional[str] = None self.token_usage = token_usage self.tool_use_id = tool_use_id self.title_hint = title_hint self.message_id = message_id self.ancestry = ancestry or [] self.has_children = has_children - self.has_markdown = has_markdown self.uuid = uuid self.parent_uuid = parent_uuid self.agent_id = agent_id # Agent ID for sidechain messages and Task results @@ -218,6 +213,16 @@ def __init__( # Children for tree-based rendering (future use) self.children: list["TemplateMessage"] = [] + @property + def is_session_header(self) -> bool: + """Check if this message is a session header (derived from content type).""" + return isinstance(self.content, SessionHeaderMessage) + + @property + def has_markdown(self) -> bool: + """Check if this message has markdown content (derived from content type).""" + return self.content.has_markdown if self.content else False + def get_immediate_children_label(self) -> str: """Generate human-readable label for immediate children.""" return _format_type_counts(self.immediate_children_by_type) @@ -1683,11 +1688,15 @@ def _render_messages( continue # Create meta once for all chunks from this message - # (QueueOperationTranscriptEntry doesn't have BaseTranscriptEntry fields) if isinstance(message, BaseTranscriptEntry): meta = create_meta(message) else: - meta = None + # QueueOperationTranscriptEntry has limited fields + meta = MessageMeta( + session_id=getattr(message, "sessionId", ""), + timestamp=getattr(message, "timestamp", ""), + uuid="", # QueueOperationTranscriptEntry has no uuid + ) # Determine effective_type for dispatching to user/assistant parsers # (queue-operation 'remove' messages are treated as user messages) @@ -1715,7 +1724,6 @@ def _render_messages( raw_timestamp=None, session_summary=current_session_summary, session_id=session_id, - is_session_header=True, message_id=None, ancestry=[], content=SessionHeaderMessage( @@ -1777,13 +1785,13 @@ def _render_messages( # (user message parsing handles all type detection internally) if effective_type == "user": content_model = create_user_message( + meta, chunk, # Pass the chunk items chunk_text, # Pre-extracted text for pattern detection is_slash_command=getattr(message, "isMeta", False), - meta=meta, ) elif effective_type == "assistant": - content_model = create_assistant_message(chunk, meta=meta) + content_model = create_assistant_message(meta, chunk) # Convert to UserSteeringMessage for queue-operation 'remove' messages if ( @@ -1823,16 +1831,6 @@ def _render_messages( if chunk_uuid and len(chunks) > 1: chunk_uuid = f"{chunk_uuid}-chunk-{chunk_idx}" - # Markdown rendering for assistant, thinking, and compacted content - has_markdown = isinstance( - content_model, - ( - AssistantTextMessage, - ThinkingMessage, - CompactedSummaryMessage, - ), - ) - template_message = TemplateMessage( message_type=chunk_message_type, raw_timestamp=timestamp, @@ -1847,7 +1845,6 @@ def _render_messages( parent_uuid=getattr(message, "parentUuid", None), is_sidechain=chunk_is_sidechain, content=content_model, - has_markdown=has_markdown, ) # Store raw text content for potential future use @@ -1867,17 +1864,17 @@ def _render_messages( tool_result: Optional[ToolItemResult] = None if isinstance(tool_item, ToolUseContent) or item_type == "tool_use": tool_result = create_tool_use_message( - tool_item, tool_use_context, meta=meta + meta, tool_item, tool_use_context ) elif ( isinstance(tool_item, ToolResultContent) or item_type == "tool_result" ): tool_result = create_tool_result_message( - tool_item, tool_use_context, meta=meta + meta, tool_item, tool_use_context ) elif isinstance(tool_item, ThinkingContent) or item_type == "thinking": - content = create_thinking_message(tool_item, meta=meta) + content = create_thinking_message(meta, tool_item) tool_result = ToolItemResult( message_type=content.message_type, message_title=content.message_title() or "Thinking", @@ -1887,9 +1884,7 @@ def _render_messages( # Handle unknown content types tool_result = ToolItemResult( message_type="unknown", - content=UnknownMessage( - meta or MessageMeta.empty(), type_name=str(type(tool_item)) - ), + content=UnknownMessage(meta, type_name=str(type(tool_item))), message_title="Unknown Content", ) @@ -1909,9 +1904,6 @@ def _render_messages( else f"{message_uuid}-tool-{len(template_messages)}" ) - # Thinking content uses markdown - tool_has_markdown = isinstance(tool_result.content, ThinkingMessage) - tool_template_message = TemplateMessage( message_type=tool_result.message_type, raw_timestamp=tool_timestamp, @@ -1926,7 +1918,6 @@ def _render_messages( uuid=tool_uuid, is_sidechain=tool_is_sidechain, content=tool_result.content, # Structured content model - has_markdown=tool_has_markdown, ) # Store raw text for Task result deduplication diff --git a/dev-docs/MESSAGE_REFACTORING2.md b/dev-docs/MESSAGE_REFACTORING2.md index 87a29c57..62238907 100644 --- a/dev-docs/MESSAGE_REFACTORING2.md +++ b/dev-docs/MESSAGE_REFACTORING2.md @@ -4,23 +4,59 @@ The goal is to achieve a cleaner, type-driven architecture where: 1. **MessageContent type is the source of truth** - No need for separate `MessageModifiers` or `MessageType` checks -2. **Inverted relationship** - Instead of `TemplateMessage.content: MessageContent`, have `MessageContent.meta: MessageMetadata` +2. **Inverted relationship** - Instead of `TemplateMessage.content: MessageContent`, have `MessageContent.meta: MessageMeta` 3. **Leaner models** - Remove derived/redundant fields like `has_children`, `has_markdown`, `is_session_header`, `raw_text_content` -4. **Modular organization** - Split into `user_models.py`, `assistant_models.py`, `tools_models.py` with corresponding parsers +4. **Modular organization** - Split into `user_models.py`, `assistant_models.py`, `tools_models.py` with corresponding factories ## Current State Analysis -### What we've achieved so far -- Content types now determine behavior (e.g., `UserSlashCommandContent` vs `UserTextContent`) -- Dispatcher pattern routes formatting based on content type -- Removed `ContentBlock` from `ContentItem` union - using our own types -- Simplified `_process_regular_message` - content type detection drives rendering +### What we've achieved ✓ + +- **Content types now determine behavior** (e.g., `UserSlashCommandMessage` vs `UserTextMessage`) +- **Dispatcher pattern** routes formatting based on content type +- **Removed `ContentBlock`** from `ContentItem` union - using our own types +- **Simplified `_process_regular_message`** - content type detection drives rendering - **CSS_CLASS_REGISTRY** derives CSS classes from content types (in `html/utils.py`) - **MessageModifiers removed** - only `is_sidechain` remains as a flag on `TemplateMessage` -- **UserSteeringContent** created for queue-operation "remove" messages +- **UserSteeringMessage** created for queue-operation "remove" messages +- **IdeNotificationContent** is now a plain dataclass (not a MessageContent subclass) + +### Factory Organization ✓ + +Completed reorganization from parsers to factories: + +``` +factories/ +├── __init__.py # Re-exports all public symbols +├── meta_factory.py # create_meta(transcript) -> MessageMeta +├── system_factory.py # create_system_message() +├── user_factory.py # create_user_message(), create_*_message() +├── assistant_factory.py # create_assistant_message(), create_thinking_message() +├── tool_factory.py # create_tool_use_message(), create_tool_result_message() +└── transcript_factory.py # create_transcript_entry(), create_content_item() +``` + +### MessageMeta as Required First Parameter ✓ + +All factory functions now require `MessageMeta` as the first positional parameter: -### Remaining goals -- `TemplateMessage` still owns `content` rather than the reverse (inverted relationship) +```python +def create_user_message(meta: MessageMeta, content_list: list[ContentItem], ...) -> ... +def create_assistant_message(meta: MessageMeta, items: list[ContentItem]) -> ... +def create_tool_use_message(meta: MessageMeta, tool_item: ContentItem, ...) -> ... +def create_tool_result_message(meta: MessageMeta, tool_item: ContentItem, ...) -> ... +def create_thinking_message(meta: MessageMeta, tool_item: ContentItem) -> ... +``` + +This ensures every `MessageContent` subclass has valid metadata. + +### Remaining Goals + +| Goal | Status | Notes | +|------|--------|-------| +| Inverted relationship | ❌ Pending | Still `TemplateMessage.content: MessageContent`, not `MessageContent.meta` | +| Leaner TemplateMessage | ❌ Pending | Still has `has_markdown`, `raw_text_content` | +| Models split | ❌ Pending | Still single `models.py` | ## Cache Considerations @@ -41,23 +77,15 @@ This means: 2. TemplateMessage is generated fresh from entries on each render 3. The relationship between MessageContent and its metadata is internal to rendering -## Modular Organization Plan +## Future: Models Split (Optional) + +If we decide to split models.py: -### Models split - `models.py` - Base classes (`MessageContent`, `TranscriptEntry`, etc.) - `user_models.py` - User message content types - `assistant_models.py` - Assistant message content types - `tools_models.py` - Tool use/result models -### Parser split -- `parser.py` - Base parsing, entry point -- `user_parser.py` - User message parsing -- `assistant_parser.py` - Assistant message parsing - -### Renderer reorganization -- `renderer.py` - Main message reorganization (`_render_messages`, tree building) -- Move `_process_*` functions to appropriate parser modules - ## Related Work See [REMOVE_ANTHROPIC_TYPES.md](REMOVE_ANTHROPIC_TYPES.md) for simplifying Anthropic SDK dependencies. diff --git a/dev-docs/TEMPLATE_MESSAGE_REFACTORING.md b/dev-docs/TEMPLATE_MESSAGE_REFACTORING.md new file mode 100644 index 00000000..fee49101 --- /dev/null +++ b/dev-docs/TEMPLATE_MESSAGE_REFACTORING.md @@ -0,0 +1,99 @@ +# TemplateMessage Simplification Plan + +## Goal + +Simplify `TemplateMessage` by moving redundant fields to `MessageMeta` (accessible via `content.meta`) and adding properties to `MessageContent` subclasses. This prepares for the eventual replacement of `TemplateMessage` with `MessageContent` directly. + +## Completed Changes ✓ + +### Phase 1: Added `message_type` property to MessageContent subclasses ✓ + +Added to these subclasses that were missing it: +- `SystemMessage` → returns "system" +- `HookSummaryMessage` → returns "system" +- `ToolResultMessage` → returns "tool_result" +- `ToolUseMessage` → returns "tool_use" +- `UnknownMessage` → returns "unknown" +- `SessionHeaderMessage` → returns "session_header" +- `DedupNoticeMessage` → returns "dedup_notice" + +### Phase 2: Added `has_markdown` property ✓ + +- Added to `MessageContent` base class (returns `False` by default) +- Override in `AssistantTextMessage` → returns `True` +- Override in `ThinkingMessage` → returns `True` +- Override in `CompactedSummaryMessage` → returns `True` + +### Phase 3: Skip tool_use_id on base ✓ + +`tool_use_id` already exists as a field on `ToolUseMessage` and `ToolResultMessage`. +No base class property needed - access via `message.content.tool_use_id` when needed. + +### Phase 4: Added `meta` field to TemplateMessage ✓ + +Added `self.meta = content.meta if content else None` for easy transition. + +### Phase 5: Updated template to use new accessors ✓ + +- Changed `message.is_session_header` → `is_session_header(message)` (helper function) +- Changed `message.has_markdown` → `message.content.has_markdown if message.content else false` +- Removed dead `session_subtitle` code from template +- Added `is_session_header` helper to `html/utils.py` and template context + +### Phase 6: Converted parameters to properties ✓ + +In `TemplateMessage`: +- Removed `is_session_header` parameter, added property that checks `isinstance(self.content, SessionHeaderMessage)` +- Removed `has_markdown` parameter, added property that returns `self.content.has_markdown if self.content else False` +- Removed `session_subtitle` assignment (was never set anyway) +- Removed unused imports (`CompactedSummaryMessage`, `ThinkingMessage`) + +## Current TemplateMessage State + +### Parameters (in `__init__`) + +| Parameter | Status | Notes | +|-----------|--------|-------| +| `message_type` | KEEP | Still used for now | +| `raw_timestamp` | KEEP | Still used | +| `session_summary` | KEEP | Complex async matching | +| `session_id` | KEEP | Still used | +| `token_usage` | KEEP | Formatted display string | +| `tool_use_id` | KEEP | Used for tool messages | +| `title_hint` | KEEP | Used for tooltips | +| `message_title` | KEEP | Display title | +| `message_id` | KEEP | Hierarchy-assigned | +| `ancestry` | KEEP | Parent chain | +| `has_children` | KEEP | Tree structure flag | +| `uuid` | KEEP | Still used | +| `parent_uuid` | KEEP | Still used | +| `agent_id` | KEEP | Still used | +| `is_sidechain` | KEEP | Still used | +| `content` | KEEP | The MessageContent | + +### Properties (derived from content) + +| Property | Derivation | +|----------|------------| +| `meta` | `content.meta if content else None` | +| `is_session_header` | `isinstance(self.content, SessionHeaderMessage)` | +| `has_markdown` | `self.content.has_markdown if self.content else False` | + +### Instance attributes (set after init) + +- `raw_text_content` - For deduplication +- Fold/unfold counts and type maps +- Pairing metadata (`is_paired`, `pair_role`, `pair_duration`) +- `children` - Tree structure + +## Future Work + +The following fields could still be derived from `content.meta` in future refactoring: +- `raw_timestamp` → `content.meta.timestamp` +- `session_id` → `content.meta.session_id` +- `uuid` → `content.meta.uuid` +- `parent_uuid` → `content.meta.parent_uuid` +- `agent_id` → `content.meta.agent_id` +- `is_sidechain` → `content.meta.is_sidechain` +- `message_type` → `content.message_type` +- `message_title` → `content.message_title()` diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 9fea42c3..614934a9 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -296,7 +296,8 @@ class IdeDiagnostic: raw_content: Optional[str] # Fallback if parsing failed @dataclass -class IdeNotificationContent(MessageContent): +class IdeNotificationContent: # NOT a MessageContent subclass + """Embedded within UserTextMessage.items alongside TextContent/ImageContent.""" opened_files: List[IdeOpenedFile] selections: List[IdeSelection] diagnostics: List[IdeDiagnostic] @@ -866,6 +867,13 @@ Sub-agent messages (from `Task` tool): - [user_formatters.py](../claude_code_log/html/user_formatters.py) - User message formatting - [assistant_formatters.py](../claude_code_log/html/assistant_formatters.py) - AssistantTextMessage, ThinkingMessage, ImageContent 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 +- [parser.py](../claude_code_log/parser.py) - JSONL parsing and text extraction +- [factories/](../claude_code_log/factories/) - Content creation from parsed data + - [user_factory.py](../claude_code_log/factories/user_factory.py) - `create_user_message()`, `create_*_message()` functions + - [assistant_factory.py](../claude_code_log/factories/assistant_factory.py) - `create_assistant_message()`, `create_thinking_message()` + - [tool_factory.py](../claude_code_log/factories/tool_factory.py) - `create_tool_use_message()`, `create_tool_result_message()` + - [system_factory.py](../claude_code_log/factories/system_factory.py) - `create_system_message()` + - [meta_factory.py](../claude_code_log/factories/meta_factory.py) - `create_meta()` - [TEMPLATE_MESSAGE_CHILDREN.md](TEMPLATE_MESSAGE_CHILDREN.md) - Tree architecture exploration -- [MESSAGE_REFACTORING.md](MESSAGE_REFACTORING.md) - Refactoring plan +- [MESSAGE_REFACTORING.md](MESSAGE_REFACTORING.md) - Refactoring plan (Phase 1) +- [MESSAGE_REFACTORING2.md](MESSAGE_REFACTORING2.md) - Refactoring plan (Phase 2) diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index c2b71c39..4f293426 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -4855,7 +4855,6 @@
Session: test_ses
-
@@ -9656,7 +9655,6 @@
Session: Tested various edge cases including markdown formatting, long text, tool errors, system messages, command outputs, special characters and emojis. All message types render correctly in the transcript viewer. • edge_cas
-
@@ -9966,7 +9964,6 @@
Session: todowrit
-
@@ -14598,7 +14595,6 @@
Session: session_
-
@@ -14685,7 +14681,6 @@
Session: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. • test_ses
-
@@ -19486,7 +19481,6 @@
Session: User learned about Python decorators, including basic decorators and parameterized decorators. Created and ran examples showing how decorators work with functions. User is now ready to implement their own timing decorator. • test_ses
-
diff --git a/test/test_ide_tags.py b/test/test_ide_tags.py index 57261022..b542f336 100644 --- a/test/test_ide_tags.py +++ b/test/test_ide_tags.py @@ -315,7 +315,7 @@ def test_parse_user_message_with_multi_item_content(self): ] content_model = create_user_message( - content_list, extract_text_content(content_list) + MessageMeta.empty(), content_list, extract_text_content(content_list) ) # Should return UserTextMessage with items diff --git a/test/test_template_utils.py b/test/test_template_utils.py index 74462096..9c44137a 100644 --- a/test/test_template_utils.py +++ b/test/test_template_utils.py @@ -7,7 +7,12 @@ from claude_code_log.factories import create_slash_command_message from claude_code_log.html import escape_html from claude_code_log.utils import format_timestamp -from claude_code_log.models import TextContent, ToolUseContent, ToolResultContent +from claude_code_log.models import ( + MessageMeta, + TextContent, + ToolUseContent, + ToolResultContent, +) class TestTimestampHandling: @@ -101,7 +106,7 @@ def test_create_slash_command_message_complete(self): """Test parsing complete slash command information.""" text = 'Testing...\ntest-cmd\n--verbose\n{"type": "text", "text": "Test content"}' - result = create_slash_command_message(text) + result = create_slash_command_message(MessageMeta.empty(), text) assert result is not None assert result.command_name == "test-cmd" @@ -112,7 +117,7 @@ def test_create_slash_command_message_missing_parts(self): """Test parsing slash command with missing parts.""" text = "minimal-cmd" - result = create_slash_command_message(text) + result = create_slash_command_message(MessageMeta.empty(), text) assert result is not None assert result.command_name == "minimal-cmd" @@ -123,7 +128,7 @@ def test_create_slash_command_message_no_command(self): """Test parsing text without command tags returns None.""" text = "This is just regular text with no command tags" - result = create_slash_command_message(text) + result = create_slash_command_message(MessageMeta.empty(), text) assert result is None # No command-name tag found @@ -131,7 +136,7 @@ def test_create_slash_command_message_malformed_json(self): """Test parsing command contents with malformed JSON.""" text = 'bad-json\n{"invalid": json' - result = create_slash_command_message(text) + result = create_slash_command_message(MessageMeta.empty(), text) assert result is not None assert result.command_name == "bad-json" @@ -183,7 +188,7 @@ def test_extract_text_content_none(self): def test_create_slash_command_message_empty_string(self): """Test parsing slash command from empty string returns None.""" - result = create_slash_command_message("") + result = create_slash_command_message(MessageMeta.empty(), "") assert result is None # No command-name tag found diff --git a/test/test_user_renderer.py b/test/test_user_renderer.py index b0ffd801..01bd03b7 100644 --- a/test/test_user_renderer.py +++ b/test/test_user_renderer.py @@ -20,6 +20,7 @@ ) from claude_code_log.models import ( CompactedSummaryMessage, + ContentItem, MessageMeta, TextContent, UserMemoryMessage, @@ -50,7 +51,7 @@ def test_create_compacted_summary_message_detected(self): ) content_list = [TextContent(type="text", text=text)] - result = create_compacted_summary_message(content_list) + result = create_compacted_summary_message(MessageMeta.empty(), content_list) assert result is not None assert isinstance(result, CompactedSummaryMessage) @@ -61,13 +62,13 @@ def test_create_compacted_summary_message_not_detected(self): text = "This is a regular user message." content_list = [TextContent(type="text", text=text)] - result = create_compacted_summary_message(content_list) + result = create_compacted_summary_message(MessageMeta.empty(), content_list) assert result is None def test_create_compacted_summary_message_empty_list(self): """Test that empty content list returns None.""" - result = create_compacted_summary_message([]) + result = create_compacted_summary_message(MessageMeta.empty(), []) assert result is None def test_create_compacted_summary_message_combines_multiple_texts(self): @@ -81,7 +82,7 @@ def test_create_compacted_summary_message_combines_multiple_texts(self): TextContent(type="text", text=third_text), ] - result = create_compacted_summary_message(content_list) + result = create_compacted_summary_message(MessageMeta.empty(), content_list) assert result is not None expected = "\n\n".join([first_text, second_text, third_text]) @@ -100,7 +101,7 @@ def test_create_user_memory_message_detected(self): """Test that user memory input tag is detected correctly.""" text = "Memory content from CLAUDE.md" - result = create_user_memory_message(text) + result = create_user_memory_message(MessageMeta.empty(), text) assert result is not None assert isinstance(result, UserMemoryMessage) @@ -110,7 +111,7 @@ def test_create_user_memory_message_with_surrounding_text(self): """Test memory tag extraction from mixed content.""" text = "Some prefix The actual memory suffix" - result = create_user_memory_message(text) + result = create_user_memory_message(MessageMeta.empty(), text) assert result is not None assert result.memory_text == "The actual memory" @@ -120,7 +121,7 @@ def test_create_user_memory_message_multiline(self): memory_content = "Line 1\nLine 2\nLine 3" text = f"{memory_content}" - result = create_user_memory_message(text) + result = create_user_memory_message(MessageMeta.empty(), text) assert result is not None assert result.memory_text == memory_content @@ -129,7 +130,7 @@ def test_create_user_memory_message_not_detected(self): """Test that regular text without tag returns None.""" text = "Regular text without memory tag." - result = create_user_memory_message(text) + result = create_user_memory_message(MessageMeta.empty(), text) assert result is None @@ -137,7 +138,7 @@ def test_create_user_memory_message_strips_whitespace(self): """Test that memory content whitespace is stripped.""" text = " \n Content with spaces \n " - result = create_user_memory_message(text) + result = create_user_memory_message(MessageMeta.empty(), text) assert result is not None assert result.memory_text == "Content with spaces" @@ -157,7 +158,7 @@ def test_compacted_summary_single_text_item(self): content_list = [TextContent(type="text", text=text)] content_model = create_user_message( - content_list, extract_text_content(content_list) + MessageMeta.empty(), content_list, extract_text_content(content_list) ) assert content_model is not None @@ -176,7 +177,7 @@ def test_compacted_summary_multiple_text_items(self): ] content_model = create_user_message( - content_list, extract_text_content(content_list) + MessageMeta.empty(), content_list, extract_text_content(content_list) ) assert content_model is not None @@ -195,7 +196,7 @@ def test_user_memory_detected(self): content_list = [TextContent(type="text", text=text)] content_model = create_user_message( - content_list, extract_text_content(content_list) + MessageMeta.empty(), content_list, extract_text_content(content_list) ) assert content_model is not None @@ -212,7 +213,7 @@ def test_regular_text(self): content_list = [TextContent(type="text", text=text)] content_model = create_user_message( - content_list, extract_text_content(content_list) + MessageMeta.empty(), content_list, extract_text_content(content_list) ) assert content_model is not None @@ -223,10 +224,10 @@ def test_regular_text(self): def test_empty_content_list(self): """Test empty content list returns None.""" - content_list = [] + content_list: list[ContentItem] = [] content_model = create_user_message( - content_list, extract_text_content(content_list) + MessageMeta.empty(), content_list, extract_text_content(content_list) ) assert content_model is None From 35156592b19c82526acc8d09f86b5db277f9abff Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 22 Dec 2025 08:39:17 +0100 Subject: [PATCH 22/57] Simplify TemplateMessage: require content/meta, remove redundant fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make content: MessageContent and meta: MessageMeta required (non-Optional) - Remove redundant parameters: raw_timestamp, session_id, tool_use_id, title_hint, parent_uuid, agent_id, is_sidechain - Add properties that directly access meta/content without fallbacks - Add message_type abstract property to MessageContent base class - Update all TemplateMessage creation sites in renderer.py - Update tests to use proper MessageMeta and MessageContent instances - Remove unnecessary None check in format_content() since content is required 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 2 +- .../html/templates/transcript.html | 2 +- claude_code_log/models.py | 9 + claude_code_log/renderer.py | 259 +++++++++--------- test/test_template_data.py | 145 +++++++--- 5 files changed, 252 insertions(+), 165 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index b3040f16..c99818e4 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -183,7 +183,7 @@ def visit(msg: TemplateMessage) -> None: # Update current message UUID for timing tracking set_timing_var("_current_msg_uuid", msg.uuid) html = self.format_content(msg) - formatted_ts = format_timestamp(msg.raw_timestamp) + formatted_ts = format_timestamp(msg.meta.timestamp if msg.meta else None) flat.append((msg, html, formatted_ts)) for child in msg.children: visit(child) diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index b4d23536..20174d68 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -107,7 +107,7 @@

🔍 Search & Filter

elif msg_emoji and (message.type != 'tool_use' or not starts_with_emoji(message.message_title)) %}{{ msg_emoji }} {% endif %}{{ message.message_title | safe }}{% endif %}
- {{ formatted_timestamp }} + {{ formatted_timestamp }}
{% if message.token_usage %} {{ message.token_usage }} diff --git a/claude_code_log/models.py b/claude_code_log/models.py index cbe7c075..63b4d970 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -105,6 +105,15 @@ class MessageContent: meta: MessageMeta + @property + def message_type(self) -> str: + """Return the message type identifier for this content. + + Subclasses MUST override this to return their specific type. + This is used for CSS classes, filtering, and type-based rendering. + """ + raise NotImplementedError("Subclasses must implement message_type property") + def message_title(self) -> Optional[str]: """Return a title for this message content, or None for default behavior. diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index fd98c312..72383572 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -153,51 +153,54 @@ def _format_type_counts(type_counts: dict[str, int]) -> str: class TemplateMessage: - """Structured message data for template rendering.""" + """Structured message data for template rendering. + + This is a lightweight wrapper around MessageContent that adds: + - Rendering metadata (message_id, ancestry, token_usage) + - Tree structure (children, fold/unfold counts) + - Pairing metadata (is_paired, pair_role, pair_duration) + + All identity/context fields come from meta (timestamp, session_id, etc.) + and content (tool_use_id, has_markdown, etc.). + """ def __init__( self, - message_type: str, - raw_timestamp: Optional[str] = None, - session_summary: Optional[str] = None, - session_id: Optional[str] = None, - token_usage: Optional[str] = None, - tool_use_id: Optional[str] = None, - title_hint: Optional[str] = None, + content: "MessageContent", + meta: "MessageMeta", + *, # Force keyword arguments after this message_title: Optional[str] = None, + token_usage: Optional[str] = None, message_id: Optional[str] = None, ancestry: Optional[list[str]] = None, has_children: bool = False, uuid: Optional[str] = None, - parent_uuid: Optional[str] = None, - agent_id: Optional[str] = None, - is_sidechain: bool = False, - content: Optional["MessageContent"] = None, + session_summary: Optional[str] = None, ): - self.type = message_type - # Structured content for rendering + # Required: content and meta self.content = content - # Shortcut to content.meta for transition period - self.meta = content.meta if content else None - self.is_sidechain = is_sidechain - self.raw_timestamp = raw_timestamp + self.meta = meta + # Display title for message header (capitalized, with decorations) + # Falls back to content.message_type if not provided self.message_title = ( - message_title if message_title is not None else message_type.title() + message_title + if message_title is not None + else content.message_type.replace("_", " ").replace("-", " ").title() ) - self.session_summary = session_summary - self.session_id = session_id + + # Rendering metadata self.token_usage = token_usage - self.tool_use_id = tool_use_id - self.title_hint = title_hint self.message_id = message_id self.ancestry = ancestry or [] self.has_children = has_children - self.uuid = uuid - self.parent_uuid = parent_uuid - self.agent_id = agent_id # Agent ID for sidechain messages and Task results + # uuid can differ from meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") + self.uuid = uuid if uuid is not None else meta.uuid + self.session_summary = session_summary + # Raw text content for deduplication (sidechain assistants vs Task results) self.raw_text_content: Optional[str] = None + # Fold/unfold counts self.immediate_children_count = 0 # Direct children only self.total_descendants_count = 0 # All descendants recursively @@ -206,22 +209,66 @@ def __init__( str, int ] = {} # {"assistant": 2, "tool_use": 3} self.total_descendants_by_type: dict[str, int] = {} # All descendants by type + # Pairing metadata 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) + + # Children for tree-based rendering self.children: list["TemplateMessage"] = [] + # -- Properties derived from content/meta -- + + @property + def type(self) -> str: + """Get message type from content.""" + return self.content.message_type + @property def is_session_header(self) -> bool: - """Check if this message is a session header (derived from content type).""" + """Check if this message is a session header.""" return isinstance(self.content, SessionHeaderMessage) @property def has_markdown(self) -> bool: - """Check if this message has markdown content (derived from content type).""" - return self.content.has_markdown if self.content else False + """Check if this message has markdown content.""" + return self.content.has_markdown + + @property + def session_id(self) -> str: + """Get session_id from meta.""" + return self.meta.session_id + + @property + def parent_uuid(self) -> Optional[str]: + """Get parent_uuid from meta.""" + return self.meta.parent_uuid + + @property + def agent_id(self) -> Optional[str]: + """Get agent_id from meta.""" + return self.meta.agent_id + + @property + def is_sidechain(self) -> bool: + """Check if this is a sidechain message.""" + return self.meta.is_sidechain + + @property + def tool_use_id(self) -> Optional[str]: + """Get tool_use_id from content (if ToolUseMessage or ToolResultMessage).""" + return getattr(self.content, "tool_use_id", None) + + @property + def title_hint(self) -> Optional[str]: + """Generate title hint from tool_use_id.""" + tool_id = self.tool_use_id + if tool_id: + # Escape for HTML attribute + escaped = tool_id.replace("&", "&").replace('"', """) + return f"ID: {escaped}" + return None def get_immediate_children_label(self) -> str: """Generate human-readable label for immediate children.""" @@ -441,9 +488,9 @@ def generate_template_messages( if getattr(msg, "sessionId", None) not in warmup_session_ids ] - # Pre-process to find and attach session summaries + # Pre-process to find session summaries with log_timing("Session summary processing", t_start): - prepare_session_summaries(messages) + session_summaries = prepare_session_summaries(messages) # Filter messages (removes summaries, warmup, empty, etc.) with log_timing("Filter messages", t_start): @@ -452,7 +499,7 @@ def generate_template_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 + filtered_messages, session_summaries ) # Pass 2: Render messages to TemplateMessage objects @@ -512,10 +559,11 @@ def generate_template_messages( # -- Session Utilities -------------------------------------------------------- -def prepare_session_summaries(messages: list[TranscriptEntry]) -> None: - """Pre-process messages to find and attach session summaries. +def prepare_session_summaries(messages: list[TranscriptEntry]) -> dict[str, str]: + """Extract session summaries from messages. - Modifies messages in place by attaching _session_summary attribute. + Returns: + Dict mapping session_id to summary text. """ session_summaries: dict[str, str] = {} uuid_to_session: dict[str, str] = {} @@ -546,12 +594,7 @@ def prepare_session_summaries(messages: list[TranscriptEntry]) -> None: ): 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]) + return session_summaries def prepare_session_navigation( @@ -659,15 +702,9 @@ def _process_system_message( title = message.message_title() or "System" return TemplateMessage( - message_type="system", - raw_timestamp=meta.timestamp, - session_id=meta.session_id, + message, + meta, message_title=title, - message_id=None, # Will be assigned by _build_message_hierarchy - ancestry=[], # Will be assigned by _build_message_hierarchy - uuid=meta.uuid, - parent_uuid=meta.parent_uuid, - content=message, ) @@ -986,13 +1023,15 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe # Calculate duration between pair messages try: - if msg.raw_timestamp and pair_last.raw_timestamp: + first_ts = msg.meta.timestamp if msg.meta else None + last_ts = pair_last.meta.timestamp if pair_last.meta else None + if first_ts and last_ts: # Parse ISO timestamps first_time = datetime.fromisoformat( - msg.raw_timestamp.replace("Z", "+00:00") + first_ts.replace("Z", "+00:00") ) last_time = datetime.fromisoformat( - pair_last.raw_timestamp.replace("Z", "+00:00") + last_ts.replace("Z", "+00:00") ) duration = last_time - first_time @@ -1497,6 +1536,7 @@ def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: def _collect_session_info( messages: list[TranscriptEntry], + session_summaries: dict[str, str], ) -> tuple[ dict[str, dict[str, Any]], # sessions list[str], # session_order @@ -1514,6 +1554,7 @@ def _collect_session_info( Args: messages: Pre-filtered list of transcript entries + session_summaries: Dict mapping session_id to summary text Returns: Tuple containing: @@ -1547,7 +1588,7 @@ def _collect_session_info( # Initialize session if new if session_id not in sessions: - current_session_summary = getattr(message, "_session_summary", None) + current_session_summary = session_summaries.get(session_id) # Get first user message content for preview first_user_message = "" @@ -1707,37 +1748,37 @@ def _render_messages( # Get session info session_id = getattr(message, "sessionId", "unknown") - session_summary = getattr(message, "_session_summary", None) + session_summary = sessions.get(session_id, {}).get("summary") # 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") + current_session_summary = session_summary session_title = ( f"{current_session_summary} • {session_id[:8]}" if current_session_summary else session_id[:8] ) + # Create meta with session_id for the session header + session_header_meta = MessageMeta( + session_id=session_id, + timestamp="", + uuid="", + ) + session_header_content = SessionHeaderMessage( + session_header_meta, + title=session_title, + session_id=session_id, + summary=current_session_summary, + ) session_header = TemplateMessage( - message_type="session_header", - raw_timestamp=None, + session_header_content, + session_header_meta, session_summary=current_session_summary, - session_id=session_id, - message_id=None, - ancestry=[], - content=SessionHeaderMessage( - MessageMeta.empty(), - title=session_title, - session_id=session_id, - summary=current_session_summary, - ), ) template_messages.append(session_header) - # Get timestamp (only for non-summary messages) - timestamp = getattr(message, "timestamp", "") - # 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 @@ -1776,12 +1817,8 @@ def _render_messages( # Extract text for pattern detection chunk_text = extract_text_content(chunk) - # Determine is_sidechain and content based on message type - content_model: Optional[MessageContent] = None - chunk_message_type = message_type - chunk_is_sidechain = getattr(message, "isSidechain", False) - # Dispatch to user or assistant parser based on effective_type + content_model: Optional[MessageContent] = None # (user message parsing handles all type detection internally) if effective_type == "user": content_model = create_user_message( @@ -1803,48 +1840,34 @@ def _render_messages( items=content_model.items, meta=meta ) - # Get message_type and message_title from content_model - if content_model is not None: - chunk_message_type = content_model.message_type - message_title = content_model.message_title() - # Override for sidechain assistant messages - if chunk_is_sidechain and isinstance( - content_model, AssistantTextMessage - ): - message_title = "Sub-assistant" - else: - # Fallback for unknown/empty content - # MessageType inherits from str, so we can use it directly - chunk_message_type = str(message_type) - message_title = chunk_message_type.title() - - # Skip empty chunks - if not chunk: + # Skip empty chunks or when no content model was created + if not chunk or content_model is None: continue + # Get message_title from content_model + message_title = content_model.message_title() + # Override for sidechain assistant messages + if meta.is_sidechain and isinstance( + content_model, AssistantTextMessage + ): + message_title = "Sub-assistant" + # Only show token usage on first chunk chunk_token_usage = token_usage_str if not token_shown else None token_shown = True # Generate UUID for this chunk (append index if multiple chunks) - chunk_uuid = getattr(message, "uuid", None) - if chunk_uuid and len(chunks) > 1: - chunk_uuid = f"{chunk_uuid}-chunk-{chunk_idx}" + chunk_uuid: Optional[str] = None + if len(chunks) > 1: + chunk_uuid = f"{meta.uuid}-chunk-{chunk_idx}" template_message = TemplateMessage( - message_type=chunk_message_type, - raw_timestamp=timestamp, - session_summary=session_summary, - session_id=session_id, - token_usage=chunk_token_usage, + content_model, + meta, message_title=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), + token_usage=chunk_token_usage, uuid=chunk_uuid, - parent_uuid=getattr(message, "parentUuid", None), - is_sidechain=chunk_is_sidechain, - content=content_model, + session_summary=session_summary, ) # Store raw text content for potential future use @@ -1855,7 +1878,6 @@ def _render_messages( else: # Special chunk: single tool_use/tool_result/thinking item tool_item = chunk - tool_timestamp = getattr(message, "timestamp", "") # Handle both custom types and Anthropic types item_type = getattr(tool_item, "type", None) @@ -1892,9 +1914,6 @@ def _render_messages( if tool_result is None: continue - # Preserve sidechain context for tool/thinking content - tool_is_sidechain = getattr(message, "isSidechain", False) - # Generate unique UUID for this tool message # Use tool_use_id if available, otherwise fall back to msg UUID + index message_uuid = getattr(message, "uuid", "no-uuid") @@ -1904,20 +1923,16 @@ def _render_messages( else f"{message_uuid}-tool-{len(template_messages)}" ) + # Skip if no content (shouldn't happen, but be safe) + if tool_result.content is None: + continue + tool_template_message = TemplateMessage( - message_type=tool_result.message_type, - raw_timestamp=tool_timestamp, - session_summary=session_summary, - session_id=session_id, - tool_use_id=tool_result.tool_use_id, - title_hint=tool_result.title_hint, + tool_result.content, + meta, 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, - is_sidechain=tool_is_sidechain, - content=tool_result.content, # Structured content model + session_summary=session_summary, ) # Store raw text for Task result deduplication @@ -2075,8 +2090,6 @@ def format_content(self, message: "TemplateMessage") -> str: Returns: Formatted string (e.g., HTML), or empty string if no handler found. """ - if message.content is None: - return "" for cls in type(message.content).__mro__: if cls is object: break diff --git a/test/test_template_data.py b/test/test_template_data.py index 45ffd8cf..3c41b85c 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -11,6 +11,14 @@ TemplateProject, TemplateSummary, ) +from claude_code_log.models import ( + MessageMeta, + UserTextMessage, + AssistantTextMessage, + SessionHeaderMessage, + ToolUseMessage, + ToolUseContent, +) class TestTemplateMessage: @@ -18,30 +26,38 @@ class TestTemplateMessage: def test_template_message_creation(self): """Test creating a TemplateMessage with all fields.""" - msg = TemplateMessage( - message_type="user", - raw_timestamp="2025-06-14T10:00:00Z", + meta = MessageMeta( + session_id="test-session", + timestamp="2025-06-14T10:00:00Z", + uuid="test-uuid", ) + content = UserTextMessage(meta=meta) + msg = TemplateMessage(content, meta) assert msg.type == "user" - assert msg.raw_timestamp == "2025-06-14T10:00:00Z" + assert msg.meta.timestamp == "2025-06-14T10:00:00Z" assert msg.message_title == "User" def test_template_message_title_capitalization(self): """Test that message_title properly capitalizes message types.""" - test_cases = [ - ("user", "User"), - ("assistant", "Assistant"), - ("system", "System"), - ("summary", "Summary"), - ] + meta = MessageMeta.empty() - for msg_type, expected_display in test_cases: - msg = TemplateMessage( - message_type=msg_type, - raw_timestamp=None, - ) - assert msg.message_title == expected_display + # Test UserTextMessage + user_content = UserTextMessage(meta=meta) + user_msg = TemplateMessage(user_content, meta) + assert user_msg.message_title == "User" + + # Test AssistantTextMessage + assistant_content = AssistantTextMessage(meta=meta) + assistant_msg = TemplateMessage(assistant_content, meta) + assert assistant_msg.message_title == "Assistant" + + # Test SessionHeaderMessage (for session type) + session_content = SessionHeaderMessage( + meta=meta, title="Test Session", session_id="test-id" + ) + session_msg = TemplateMessage(session_content, meta) + assert session_msg.message_title == "Session Header" class TestTemplateProject: @@ -370,17 +386,41 @@ class TestTemplateMessageTree: """Test TemplateMessage tree building and flatten functionality.""" def _create_message( - self, msg_type: str, msg_id: str | None = None, ancestry: list | None = None + self, + msg_type: str, + msg_id: str | None = None, + ancestry: list[str] | None = None, ) -> TemplateMessage: """Helper to create a minimal TemplateMessage for testing.""" - msg = TemplateMessage( - message_type=msg_type, - raw_timestamp="2025-06-14T10:00:00Z", + meta = MessageMeta( + session_id="test-session", + timestamp="2025-06-14T10:00:00Z", + uuid=msg_id or "test-uuid", ) - if msg_id: - msg.message_id = msg_id - if ancestry: - msg.ancestry = ancestry + + # Create appropriate content based on message type + if msg_type == "user": + content = UserTextMessage(meta=meta) + elif msg_type == "assistant": + content = AssistantTextMessage(meta=meta) + elif msg_type == "tool_use": + content = ToolUseMessage( + meta=meta, + input=ToolUseContent( + type="tool_use", id="test-id", name="TestTool", input={} + ), + tool_use_id="test-id", + tool_name="TestTool", + ) + elif msg_type == "session": + content = SessionHeaderMessage( + meta=meta, title="Test Session", session_id="test-session" + ) + else: + # Fallback to UserTextMessage for unknown types + content = UserTextMessage(meta=meta) + + msg = TemplateMessage(content, meta, message_id=msg_id, ancestry=ancestry) return msg def test_flatten_single_message(self): @@ -524,33 +564,58 @@ def test_tree_built_from_representative_messages(self): def test_flatten_roundtrip_preserves_count(self): """Test that flatten of built tree gives same count as input.""" # Create a manual tree and verify flatten returns all messages + meta_session = MessageMeta( + session_id="session-1", + timestamp="2025-06-14T10:00:00Z", + uuid="uuid-session", + ) + session_content = SessionHeaderMessage( + meta=meta_session, title="Test Session", session_id="session-1" + ) root = TemplateMessage( - message_type="session", - raw_timestamp="2025-06-14T10:00:00Z", + session_content, meta_session, message_id="session-1", ancestry=[] ) - root.message_id = "session-1" - root.ancestry = [] + meta_user = MessageMeta( + session_id="session-1", + timestamp="2025-06-14T10:00:01Z", + uuid="uuid-1", + ) + user_content = UserTextMessage(meta=meta_user) user = TemplateMessage( - message_type="user", - raw_timestamp="2025-06-14T10:00:01Z", + user_content, meta_user, message_id="d-1", ancestry=["session-1"] ) - user.message_id = "d-1" - user.ancestry = ["session-1"] + meta_assistant = MessageMeta( + session_id="session-1", + timestamp="2025-06-14T10:00:02Z", + uuid="uuid-2", + ) + assistant_content = AssistantTextMessage(meta=meta_assistant) assistant = TemplateMessage( - message_type="assistant", - raw_timestamp="2025-06-14T10:00:02Z", + assistant_content, + meta_assistant, + message_id="d-2", + ancestry=["session-1", "d-1"], ) - assistant.message_id = "d-2" - assistant.ancestry = ["session-1", "d-1"] + meta_tool = MessageMeta( + session_id="session-1", + timestamp="2025-06-14T10:00:03Z", + uuid="uuid-3", + ) + tool_content = ToolUseMessage( + meta=meta_tool, + input=ToolUseContent(type="tool_use", id="d-3", name="TestTool", input={}), + tool_use_id="d-3", + tool_name="TestTool", + ) tool = TemplateMessage( - message_type="tool_use", - raw_timestamp="2025-06-14T10:00:03Z", + tool_content, + meta_tool, + message_id="d-3", + ancestry=["session-1", "d-1", "d-2"], ) - tool.message_id = "d-3" - tool.ancestry = ["session-1", "d-1", "d-2"] # Build tree manually assistant.children = [tool] From 6cf9427fecf2afea1e2d4926855cca37f8aaf1fd Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 22 Dec 2025 12:49:29 +0100 Subject: [PATCH 23/57] Remove unused TemplateMessage.session_summary field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This field was set but never read anywhere - the session summary is displayed via the sessions dict in session_nav.html, not from TemplateMessage. SessionHeaderMessage.summary is kept for potential future use (e.g., tooltip on session header). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 72383572..2a53840f 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -175,7 +175,6 @@ def __init__( ancestry: Optional[list[str]] = None, has_children: bool = False, uuid: Optional[str] = None, - session_summary: Optional[str] = None, ): # Required: content and meta self.content = content @@ -196,7 +195,6 @@ def __init__( self.has_children = has_children # uuid can differ from meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") self.uuid = uuid if uuid is not None else meta.uuid - self.session_summary = session_summary # Raw text content for deduplication (sidechain assistants vs Task results) self.raw_text_content: Optional[str] = None @@ -1775,7 +1773,6 @@ def _render_messages( session_header = TemplateMessage( session_header_content, session_header_meta, - session_summary=current_session_summary, ) template_messages.append(session_header) @@ -1867,7 +1864,6 @@ def _render_messages( message_title=message_title, token_usage=chunk_token_usage, uuid=chunk_uuid, - session_summary=session_summary, ) # Store raw text content for potential future use @@ -1932,7 +1928,6 @@ def _render_messages( meta, message_title=tool_result.message_title, uuid=tool_uuid, - session_summary=session_summary, ) # Store raw text for Task result deduplication From 632dd2f8d1294c3ba526d17194e45cf3e0692ab0 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 22 Dec 2025 18:28:46 +0100 Subject: [PATCH 24/57] Clean up outdated comments about moved code and Anthropic types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "NOTE: Content formatters have been moved" comment block - Remove "Note: Message creation functions have been moved" comment block - Replace "Anthropic type" comments with "duck-typed objects" for accuracy - Simplify parser.py docstring about SDK type compatibility These comments were obsolete breadcrumbs from refactoring or inaccurately described duck typing as "Anthropic SDK types". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 4 ++-- claude_code_log/factories/user_factory.py | 4 ++-- claude_code_log/parser.py | 3 +-- claude_code_log/renderer.py | 16 +--------------- 4 files changed, 6 insertions(+), 21 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 7cb54da0..b616b6cd 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -274,7 +274,7 @@ def create_tool_use_message( Returns: ToolItemResult with tool_use content model, or None if item should be skipped """ - # Convert Anthropic type to our format if necessary + # Convert to Pydantic model if necessary (handles duck-typed objects) if not isinstance(tool_item, ToolUseContent): tool_use = ToolUseContent( type="tool_use", @@ -330,7 +330,7 @@ def create_tool_result_message( Returns: ToolItemResult with tool_result content model, or None if item should be skipped """ - # Convert Anthropic type to our format if necessary + # Convert to Pydantic model if necessary (handles duck-typed objects) if not isinstance(tool_item, ToolResultContent): tool_result = ToolResultContent( type="tool_result", diff --git a/claude_code_log/factories/user_factory.py b/claude_code_log/factories/user_factory.py index 4f63842f..45cebe77 100644 --- a/claude_code_log/factories/user_factory.py +++ b/claude_code_log/factories/user_factory.py @@ -322,7 +322,7 @@ def create_compacted_summary_message( return None # Combine all text content for compacted summaries - # Use hasattr check to handle both TextContent models and SDK TextBlock objects + # Use hasattr check to handle duck-typed objects texts = cast( list[str], [item.text for item in content_list if hasattr(item, "text")], # type: ignore[union-attr] @@ -465,7 +465,7 @@ def create_user_message( # ImageContent model - use as-is items.append(item) elif hasattr(item, "source") and getattr(item, "type", None) == "image": - # Anthropic ImageContent - convert to our model + # Duck-typed image content - convert to our Pydantic model items.append(ImageContent.model_validate(item.model_dump())) # type: ignore[union-attr] # Return UserTextMessage with items list diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 270fb265..6862327f 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -17,8 +17,7 @@ def extract_text_content(content: Optional[list[ContentItem]]) -> str: """Extract text content from Claude message content structure. - Supports both custom models (TextContent, ThinkingContent) and official - Anthropic SDK types (TextBlock, ThinkingBlock). + Supports both Pydantic models and duck-typed objects with 'text' attribute. """ if not content: return "" diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2a53840f..3618917c 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -62,15 +62,7 @@ ) -# -- 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 +# -- Helper Functions --------------------------------------------------------- def _format_type_counts(type_counts: dict[str, int]) -> str: @@ -660,10 +652,6 @@ def prepare_session_navigation( # -- Message Processing Functions --------------------------------------------- -# Note: Message creation functions have been moved to the factories package: -# - factories/user_factory.py: create_user_message, create_slash_command_message, etc. -# - factories/assistant_factory.py: create_assistant_message, create_thinking_message -# - factories/system_factory.py: create_system_message def _process_system_message( @@ -1874,8 +1862,6 @@ def _render_messages( else: # Special chunk: single tool_use/tool_result/thinking item tool_item = chunk - - # Handle both custom types and Anthropic types item_type = getattr(tool_item, "type", None) # Dispatch to appropriate handler based on item type From 68c4c0af7da860f9af1ab6ee43a0305cc1d83fbb Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 22 Dec 2025 19:30:58 +0100 Subject: [PATCH 25/57] Remove duck-typing fallbacks in favor of strict type checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since content items are now proper Pydantic models (part of ContentItem union), we can use isinstance() checks directly instead of duck-typing with hasattr/getattr. Changes: - tool_factory.py: Make parameter types stricter (ToolUseContent, ToolResultContent) and remove duck-typed conversion code - user_factory.py: Use isinstance(item, TextContent) instead of hasattr(item, "text") - parser.py: Simplify extract_text_content to a one-liner using isinstance - renderer.py: Remove redundant "or item_type == ..." checks, remove dead None check This reduces code by ~47 lines while improving type safety. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 41 ++++++----------------- claude_code_log/factories/user_factory.py | 10 ++---- claude_code_log/parser.py | 20 ++--------- claude_code_log/renderer.py | 16 +++------ 4 files changed, 20 insertions(+), 67 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index b616b6cd..3e2edfa6 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -21,7 +21,6 @@ AskUserQuestionItem, AskUserQuestionOption, BashInput, - ContentItem, EditInput, EditItem, ExitPlanModeInput, @@ -261,29 +260,19 @@ class ToolItemResult: def create_tool_use_message( meta: MessageMeta, - tool_item: ContentItem, + tool_use: ToolUseContent, tool_use_context: dict[str, ToolUseContent], -) -> Optional[ToolItemResult]: +) -> ToolItemResult: """Create ToolItemResult from a tool_use content item. Args: - tool_item: The tool use content item + tool_use: The tool use content item tool_use_context: Dict to populate with tool_use_id -> ToolUseContent mapping - meta: Optional message metadata + meta: Message metadata Returns: - ToolItemResult with tool_use content model, or None if item should be skipped + ToolItemResult with tool_use content model """ - # Convert to Pydantic model if necessary (handles duck-typed objects) - 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 # Parse tool input once, use for both title and message content parsed = create_tool_input(tool_use.name, tool_use.input) @@ -317,29 +306,19 @@ def create_tool_use_message( def create_tool_result_message( meta: MessageMeta, - tool_item: ContentItem, + tool_result: ToolResultContent, tool_use_context: dict[str, ToolUseContent], -) -> Optional[ToolItemResult]: +) -> ToolItemResult: """Create ToolItemResult from a tool_result content item. Args: - tool_item: The tool result content item + tool_result: The tool result content item tool_use_context: Dict with tool_use_id -> ToolUseContent mapping - meta: Optional message metadata + meta: Message metadata Returns: - ToolItemResult with tool_result content model, or None if item should be skipped + ToolItemResult with tool_result content model """ - # Convert to Pydantic model if necessary (handles duck-typed objects) - 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: - tool_result = tool_item # Get file_path and tool_name from tool_use context for specialized rendering result_file_path: Optional[str] = None diff --git a/claude_code_log/factories/user_factory.py b/claude_code_log/factories/user_factory.py index 45cebe77..36e8ad68 100644 --- a/claude_code_log/factories/user_factory.py +++ b/claude_code_log/factories/user_factory.py @@ -314,19 +314,15 @@ def create_compacted_summary_message( Returns: CompactedSummaryMessage if first text is a compacted summary, None otherwise """ - if not content_list or not hasattr(content_list[0], "text"): + if not content_list or not isinstance(content_list[0], TextContent): return None - first_text = getattr(content_list[0], "text", "") + first_text = 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 duck-typed objects - texts = cast( - list[str], - [item.text for item in content_list if hasattr(item, "text")], # type: ignore[union-attr] - ) + texts = [item.text for item in content_list if isinstance(item, TextContent)] all_text = "\n\n".join(texts) return CompactedSummaryMessage(summary_text=all_text, meta=meta) diff --git a/claude_code_log/parser.py b/claude_code_log/parser.py index 6862327f..fc48cd1d 100644 --- a/claude_code_log/parser.py +++ b/claude_code_log/parser.py @@ -11,28 +11,14 @@ from datetime import datetime from typing import Optional -from .models import ContentItem, ThinkingContent +from .models import ContentItem, TextContent def extract_text_content(content: Optional[list[ContentItem]]) -> str: - """Extract text content from Claude message content structure. - - Supports both Pydantic models and duck-typed objects with 'text' attribute. - """ + """Extract text content from Claude message content structure.""" if not content: return "" - text_parts: list[str] = [] - for item in content: - # Skip thinking content - if ( - isinstance(item, ThinkingContent) - or getattr(item, "type", None) == "thinking" - ): - continue - # Handle text content - if hasattr(item, "text"): - text_parts.append(getattr(item, "text")) # type: ignore[arg-type] - return "\n".join(text_parts) + return "\n".join(item.text for item in content if isinstance(item, TextContent)) def parse_timestamp(timestamp_str: str) -> Optional[datetime]: diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 3618917c..48da37b1 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1862,22 +1862,18 @@ def _render_messages( else: # Special chunk: single tool_use/tool_result/thinking item tool_item = chunk - item_type = getattr(tool_item, "type", None) # Dispatch to appropriate handler based on item type - tool_result: Optional[ToolItemResult] = None - if isinstance(tool_item, ToolUseContent) or item_type == "tool_use": + tool_result: ToolItemResult + if isinstance(tool_item, ToolUseContent): tool_result = create_tool_use_message( meta, tool_item, tool_use_context ) - elif ( - isinstance(tool_item, ToolResultContent) - or item_type == "tool_result" - ): + elif isinstance(tool_item, ToolResultContent): tool_result = create_tool_result_message( meta, tool_item, tool_use_context ) - elif isinstance(tool_item, ThinkingContent) or item_type == "thinking": + elif isinstance(tool_item, ThinkingContent): content = create_thinking_message(meta, tool_item) tool_result = ToolItemResult( message_type=content.message_type, @@ -1892,10 +1888,6 @@ def _render_messages( message_title="Unknown Content", ) - # Skip if handler returned None (e.g., unsupported image types) - if tool_result is None: - continue - # Generate unique UUID for this tool message # Use tool_use_id if available, otherwise fall back to msg UUID + index message_uuid = getattr(message, "uuid", "no-uuid") From 8f182641df46134e9d33c54705e9214150bd84ec Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 22 Dec 2025 19:43:52 +0100 Subject: [PATCH 26/57] Inline _process_system_message function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function was a simple wrapper around create_system_message that created a TemplateMessage. Inlined at the single call site for clarity. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 58 +++++++------------------------------ 1 file changed, 11 insertions(+), 47 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 48da37b1..789b301f 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -43,6 +43,7 @@ as_user_entry, create_assistant_message, create_meta, + create_system_message, create_thinking_message, create_tool_result_message, create_tool_use_message, @@ -651,49 +652,6 @@ def prepare_session_navigation( return session_nav -# -- Message Processing Functions --------------------------------------------- - - -def _process_system_message( - transcript: SystemTranscriptEntry, -) -> Optional[TemplateMessage]: - """Process a system transcript entry into a TemplateMessage. - - Handles: - - Hook summaries (subtype="stop_hook_summary") - - Other system messages with level-specific styling (info, warning, error) - - Args: - transcript: The system transcript entry to process - - Returns: - TemplateMessage, or None if the message should be skipped - - 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 .factories import create_system_message - - # Create structured message content (with meta attached) - message = create_system_message(transcript) - if message is None: - return None - - # Get metadata from the message content - meta = message.meta - assert meta is not None, "create_system_message should always set meta" - - # Get title from message (uses message_title() method) - title = message.message_title() or "System" - - return TemplateMessage( - message, - meta, - message_title=title, - ) - - # Type alias for chunk output: either a list of regular items or a single special item ContentChunk = list[ContentItem] | ContentItem @@ -1677,11 +1635,17 @@ def _render_messages( for message in messages: message_type = message.type - # Handle system messages separately (already filtered in pass 1) + # Handle system messages (already filtered in pass 1) if isinstance(message, SystemTranscriptEntry): - system_template_message = _process_system_message(message) - if system_template_message: - template_messages.append(system_template_message) + system_content = create_system_message(message) + if system_content: + template_messages.append( + TemplateMessage( + system_content, + system_content.meta, + message_title=system_content.message_title() or "System", + ) + ) continue # Handle queue-operation 'remove' messages as user messages From 3d0e8e756940668b1bc53df686b8cb988325b311 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 22 Dec 2025 19:56:44 +0100 Subject: [PATCH 27/57] Make has_children a property derived from children list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of tracking has_children as a separate boolean that must be kept in sync with the children list, derive it automatically as a property: `bool(self.children)`. This removes the risk of has_children getting out of sync with the actual children list, and simplifies the code by removing one constructor parameter and one explicit assignment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 789b301f..45bd986a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -166,7 +166,6 @@ def __init__( token_usage: Optional[str] = None, message_id: Optional[str] = None, ancestry: Optional[list[str]] = None, - has_children: bool = False, uuid: Optional[str] = None, ): # Required: content and meta @@ -185,7 +184,6 @@ def __init__( self.token_usage = token_usage self.message_id = message_id self.ancestry = ancestry or [] - self.has_children = has_children # uuid can differ from meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") self.uuid = uuid if uuid is not None else meta.uuid @@ -226,6 +224,11 @@ def has_markdown(self) -> bool: """Check if this message has markdown content.""" return self.content.has_markdown + @property + def has_children(self) -> bool: + """Check if this message has any children.""" + return bool(self.children) + @property def session_id(self) -> str: """Get session_id from meta.""" @@ -1107,10 +1110,9 @@ def _build_message_hierarchy(messages: list[TemplateMessage]) -> None: def _mark_messages_with_children(messages: list[TemplateMessage]) -> None: - """Mark messages that have children and calculate descendant counts. + """Calculate child and descendant counts for messages. Efficiently calculates: - - has_children: Whether message has any children - immediate_children_count: Count of direct children only - total_descendants_count: Count of all descendants recursively @@ -1145,7 +1147,6 @@ def _mark_messages_with_children(messages: list[TemplateMessage]) -> None: if immediate_parent_id in message_by_id: parent = message_by_id[immediate_parent_id] parent.immediate_children_count += 1 - parent.has_children = True # Track by type parent.immediate_children_by_type[msg_type] = ( parent.immediate_children_by_type.get(msg_type, 0) + 1 From 2f65852937b828ac86f46d443cd2877cf7d20882 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 22 Dec 2025 21:27:05 +0100 Subject: [PATCH 28/57] Remove unused chunk_uuid generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunk_uuid (`{uuid}-chunk-{idx}`) was generated for multi-chunk messages but never used for anything meaningful: - Dedup notices only target TOOL_RESULT messages (which use tool_use_id) - The uuid_to_id mapping would just overwrite with the last chunk - Pairing logic only uses system message uuids (never chunked) For tool messages, uuid is still passed (using tool_use_id) since dedup notices need to resolve to target message_id. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 45bd986a..e410e39a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1756,7 +1756,7 @@ def _render_messages( # Process each chunk - regular chunks (list) become text/image messages, # special chunks (single item) become tool/thinking messages - for chunk_idx, chunk in enumerate(chunks): + for chunk in chunks: # Regular chunk: list of text/image items if isinstance(chunk, list): # Skip text chunks for sidechain user messages @@ -1806,17 +1806,11 @@ def _render_messages( chunk_token_usage = token_usage_str if not token_shown else None token_shown = True - # Generate UUID for this chunk (append index if multiple chunks) - chunk_uuid: Optional[str] = None - if len(chunks) > 1: - chunk_uuid = f"{meta.uuid}-chunk-{chunk_idx}" - template_message = TemplateMessage( content_model, meta, message_title=message_title, token_usage=chunk_token_usage, - uuid=chunk_uuid, ) # Store raw text content for potential future use From 8e574ad1141bd3e97dd9a8e6e2281f70c0dc3b25 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 23 Dec 2025 10:27:48 +0100 Subject: [PATCH 29/57] Remove dead code (flatten methods) and reorganize _format_type_counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `flatten` and `flatten_all` methods from TemplateMessage (unused) - Remove corresponding tests in test_template_data.py - Move `_format_type_counts` after TemplateMessage class definition (function is used by TemplateMessage methods, now closer to usage) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 171 ++++++++++++++------------------- test/test_template_data.py | 185 +----------------------------------- 2 files changed, 73 insertions(+), 283 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index e410e39a..e2634db2 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -63,85 +63,6 @@ ) -# -- Helper Functions --------------------------------------------------------- - - -def _format_type_counts(type_counts: dict[str, int]) -> str: - """Format type counts into human-readable label. - - Args: - type_counts: Dictionary of message type to count - - Returns: - Human-readable label like "3 assistant, 4 tools" or "8 messages" - - Examples: - {"assistant": 3, "tool_use": 4} -> "3 assistant, 4 tools" - {"tool_use": 2, "tool_result": 2} -> "2 tool pairs" - {"assistant": 1} -> "1 assistant" - {"thinking": 3} -> "3 thoughts" - """ - if not type_counts: - return "0 messages" - - # Type name mapping for better readability - type_labels = { - "assistant": ("assistant", "assistants"), - "user": ("user", "users"), - "tool_use": ("tool", "tools"), - "tool_result": ("result", "results"), - "thinking": ("thought", "thoughts"), - "system": ("system", "systems"), - "system-warning": ("warning", "warnings"), - "system-error": ("error", "errors"), - "system-info": ("info", "infos"), - "sidechain": ("task", "tasks"), - } - - # Handle special case: tool_use and tool_result together = "tool pairs" - # Create a modified counts dict that combines tool pairs - modified_counts = dict(type_counts) - if ( - "tool_use" in modified_counts - and "tool_result" in modified_counts - and modified_counts["tool_use"] == modified_counts["tool_result"] - ): - # Replace tool_use and tool_result with tool_pair - pair_count = modified_counts["tool_use"] - del modified_counts["tool_use"] - del modified_counts["tool_result"] - modified_counts["tool_pair"] = pair_count - - # Add tool_pair label - type_labels_with_pairs = { - **type_labels, - "tool_pair": ("tool pair", "tool pairs"), - } - - # Build label parts - parts: list[str] = [] - for msg_type, count in sorted( - modified_counts.items(), key=lambda x: x[1], reverse=True - ): - singular, plural = type_labels_with_pairs.get( - msg_type, (msg_type, f"{msg_type}s") - ) - label = singular if count == 1 else plural - parts.append(f"{count} {label}") - - # Return combined label - if len(parts) == 1: - return parts[0] - elif len(parts) == 2: - return f"{parts[0]}, {parts[1]}" - else: - # For 3+ types, show top 2 and "X more" - remaining = sum(type_counts.values()) - sum( - type_counts[t] for t in list(type_counts.keys())[:2] - ) - return f"{parts[0]}, {parts[1]}, {remaining} more" - - # -- Template Classes --------------------------------------------------------- @@ -272,29 +193,81 @@ 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 +def _format_type_counts(type_counts: dict[str, int]) -> str: + """Format type counts into human-readable label. - @staticmethod - def flatten_all(messages: list["TemplateMessage"]) -> list["TemplateMessage"]: - """Flatten a list of root messages into a single flat list. + Args: + type_counts: Dictionary of message type to count - 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 + Returns: + Human-readable label like "3 assistant, 4 tools" or "8 messages" + + Examples: + {"assistant": 3, "tool_use": 4} -> "3 assistant, 4 tools" + {"tool_use": 2, "tool_result": 2} -> "2 tool pairs" + {"assistant": 1} -> "1 assistant" + {"thinking": 3} -> "3 thoughts" + """ + if not type_counts: + return "0 messages" + + # Type name mapping for better readability + type_labels = { + "assistant": ("assistant", "assistants"), + "user": ("user", "users"), + "tool_use": ("tool", "tools"), + "tool_result": ("result", "results"), + "thinking": ("thought", "thoughts"), + "system": ("system", "systems"), + "system-warning": ("warning", "warnings"), + "system-error": ("error", "errors"), + "system-info": ("info", "infos"), + "sidechain": ("task", "tasks"), + } + + # Handle special case: tool_use and tool_result together = "tool pairs" + # Create a modified counts dict that combines tool pairs + modified_counts = dict(type_counts) + if ( + "tool_use" in modified_counts + and "tool_result" in modified_counts + and modified_counts["tool_use"] == modified_counts["tool_result"] + ): + # Replace tool_use and tool_result with tool_pair + pair_count = modified_counts["tool_use"] + del modified_counts["tool_use"] + del modified_counts["tool_result"] + modified_counts["tool_pair"] = pair_count + + # Add tool_pair label + type_labels_with_pairs = { + **type_labels, + "tool_pair": ("tool pair", "tool pairs"), + } + + # Build label parts + parts: list[str] = [] + for msg_type, count in sorted( + modified_counts.items(), key=lambda x: x[1], reverse=True + ): + singular, plural = type_labels_with_pairs.get( + msg_type, (msg_type, f"{msg_type}s") + ) + label = singular if count == 1 else plural + parts.append(f"{count} {label}") + + # Return combined label + if len(parts) == 1: + return parts[0] + elif len(parts) == 2: + return f"{parts[0]}, {parts[1]}" + else: + # For 3+ types, show top 2 and "X more" + remaining = sum(type_counts.values()) - sum( + type_counts[t] for t in list(type_counts.keys())[:2] + ) + return f"{parts[0]}, {parts[1]}, {remaining} more" class TemplateProject: diff --git a/test/test_template_data.py b/test/test_template_data.py index 3c41b85c..7a8dd606 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -383,7 +383,7 @@ def test_malformed_message_handling(self): class TestTemplateMessageTree: - """Test TemplateMessage tree building and flatten functionality.""" + """Test TemplateMessage tree building.""" def _create_message( self, @@ -423,126 +423,12 @@ def _create_message( msg = TemplateMessage(content, meta, message_id=msg_id, ancestry=ancestry) return msg - def test_flatten_single_message(self): - """Test flattening a single message with no children.""" - msg = self._create_message("user", "m1", []) - - result = msg.flatten() - - assert len(result) == 1 - assert result[0] is msg - - def test_flatten_with_children(self): - """Test flattening a message with children.""" - parent = self._create_message("user", "m1", []) - child1 = self._create_message("assistant", "m2", ["m1"]) - child2 = self._create_message("tool_use", "m3", ["m1"]) - - parent.children = [child1, child2] - - result = parent.flatten() - - assert len(result) == 3 - assert result[0] is parent - assert result[1] is child1 - assert result[2] is child2 - - def test_flatten_nested_children(self): - """Test flattening with nested children (depth-first order).""" - root = self._create_message("user", "m1", []) - child = self._create_message("assistant", "m2", ["m1"]) - grandchild = self._create_message("tool_use", "m3", ["m1", "m2"]) - - child.children = [grandchild] - root.children = [child] - - result = root.flatten() - - assert len(result) == 3 - # Depth-first order: root, child, grandchild - assert result[0] is root - assert result[1] is child - assert result[2] is grandchild - - def test_flatten_multiple_branches(self): - """Test flattening with multiple branches (depth-first order).""" - root = self._create_message("user", "m1", []) - branch1 = self._create_message("assistant", "m2", ["m1"]) - branch2 = self._create_message("assistant", "m3", ["m1"]) - leaf1 = self._create_message("tool_use", "m4", ["m1", "m2"]) - leaf2 = self._create_message("tool_use", "m5", ["m1", "m3"]) - - branch1.children = [leaf1] - branch2.children = [leaf2] - root.children = [branch1, branch2] - - result = root.flatten() - - # Depth-first: root -> branch1 -> leaf1 -> branch2 -> leaf2 - assert len(result) == 5 - assert result[0] is root - assert result[1] is branch1 - assert result[2] is leaf1 - assert result[3] is branch2 - assert result[4] is leaf2 - - def test_flatten_all_single_root(self): - """Test flatten_all with a single root message.""" - root = self._create_message("user", "m1", []) - child = self._create_message("assistant", "m2", ["m1"]) - root.children = [child] - - result = TemplateMessage.flatten_all([root]) - - assert len(result) == 2 - assert result[0] is root - assert result[1] is child - - def test_flatten_all_multiple_roots(self): - """Test flatten_all with multiple root messages.""" - root1 = self._create_message("user", "m1", []) - child1 = self._create_message("assistant", "m2", ["m1"]) - root1.children = [child1] - - root2 = self._create_message("user", "m3", []) - child2 = self._create_message("assistant", "m4", ["m3"]) - root2.children = [child2] - - result = TemplateMessage.flatten_all([root1, root2]) - - assert len(result) == 4 - assert result[0] is root1 - assert result[1] is child1 - assert result[2] is root2 - assert result[3] is child2 - - def test_flatten_all_empty_list(self): - """Test flatten_all with an empty list.""" - result = TemplateMessage.flatten_all([]) - - assert result == [] - def test_children_field_default_empty(self): """Test that children field defaults to empty list.""" msg = self._create_message("user") assert msg.children == [] - def test_flatten_preserves_order(self): - """Test that flatten preserves insertion order of children.""" - root = self._create_message("user", "m1", []) - children = [ - self._create_message("assistant", f"m{i}", ["m1"]) for i in range(2, 7) - ] - root.children = children - - result = root.flatten() - - # First element is root, rest are children in order - assert result[0] is root - for i, child in enumerate(children): - assert result[i + 1] is child - class TestTreeBuildingIntegration: """Integration tests for tree building with real transcript data.""" @@ -561,75 +447,6 @@ def test_tree_built_from_representative_messages(self): # _build_message_tree is private. This test just verifies the # tree building doesn't break normal HTML generation. - def test_flatten_roundtrip_preserves_count(self): - """Test that flatten of built tree gives same count as input.""" - # Create a manual tree and verify flatten returns all messages - meta_session = MessageMeta( - session_id="session-1", - timestamp="2025-06-14T10:00:00Z", - uuid="uuid-session", - ) - session_content = SessionHeaderMessage( - meta=meta_session, title="Test Session", session_id="session-1" - ) - root = TemplateMessage( - session_content, meta_session, message_id="session-1", ancestry=[] - ) - - meta_user = MessageMeta( - session_id="session-1", - timestamp="2025-06-14T10:00:01Z", - uuid="uuid-1", - ) - user_content = UserTextMessage(meta=meta_user) - user = TemplateMessage( - user_content, meta_user, message_id="d-1", ancestry=["session-1"] - ) - - meta_assistant = MessageMeta( - session_id="session-1", - timestamp="2025-06-14T10:00:02Z", - uuid="uuid-2", - ) - assistant_content = AssistantTextMessage(meta=meta_assistant) - assistant = TemplateMessage( - assistant_content, - meta_assistant, - message_id="d-2", - ancestry=["session-1", "d-1"], - ) - - meta_tool = MessageMeta( - session_id="session-1", - timestamp="2025-06-14T10:00:03Z", - uuid="uuid-3", - ) - tool_content = ToolUseMessage( - meta=meta_tool, - input=ToolUseContent(type="tool_use", id="d-3", name="TestTool", input={}), - tool_use_id="d-3", - tool_name="TestTool", - ) - tool = TemplateMessage( - tool_content, - meta_tool, - message_id="d-3", - ancestry=["session-1", "d-1", "d-2"], - ) - - # Build tree manually - assistant.children = [tool] - user.children = [assistant] - root.children = [user] - - # Flatten and verify - flat = TemplateMessage.flatten_all([root]) - assert len(flat) == 4 - assert flat[0].message_id == "session-1" - assert flat[1].message_id == "d-1" - assert flat[2].message_id == "d-2" - assert flat[3].message_id == "d-3" - if __name__ == "__main__": pytest.main([__file__]) From bc8eee09d3bc98cf52e4029d628dfd7168e37136 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Tue, 23 Dec 2025 15:17:38 +0100 Subject: [PATCH 30/57] Add is_meta to MessageMeta and refactor _render_messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_meta field to MessageMeta for slash command detection - Update create_meta() to extract isMeta from transcript entries - Move meta creation earlier in _render_messages loop - Replace getattr(message, ...) calls with direct meta field access - Consolidate QueueOperationTranscriptEntry handling in one branch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/meta_factory.py | 1 + claude_code_log/models.py | 1 + claude_code_log/renderer.py | 42 +++++++++-------------- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/claude_code_log/factories/meta_factory.py b/claude_code_log/factories/meta_factory.py index 8dc46740..ee6f0d42 100644 --- a/claude_code_log/factories/meta_factory.py +++ b/claude_code_log/factories/meta_factory.py @@ -26,6 +26,7 @@ def create_meta(transcript: BaseTranscriptEntry) -> MessageMeta: parent_uuid=transcript.parentUuid, # Context fields is_sidechain=transcript.isSidechain, + is_meta=getattr(transcript, "isMeta", False) or False, agent_id=transcript.agentId, cwd=transcript.cwd, git_branch=transcript.gitBranch, diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 63b4d970..c3c06376 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -69,6 +69,7 @@ class MessageMeta: # Context fields is_sidechain: bool = False + is_meta: bool = False # User slash command (isMeta=True in transcript) agent_id: Optional[str] = None cwd: str = "" git_branch: Optional[str] = None diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index e2634db2..6c057df7 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -12,7 +12,6 @@ from datetime import datetime from .models import ( - BaseTranscriptEntry, MessageMeta, MessageType, TranscriptEntry, @@ -1622,18 +1621,29 @@ def _render_messages( ) continue + # Skip summary messages (should be filtered in pass 1, but be defensive) + if isinstance(message, SummaryTranscriptEntry): + continue + # Handle queue-operation 'remove' messages as user messages if isinstance(message, QueueOperationTranscriptEntry): message_content = message.content if message.content else [] message_type = MessageType.QUEUE_OPERATION + # QueueOperationTranscriptEntry has limited fields (no uuid, agentId, etc.) + meta = MessageMeta( + session_id=message.sessionId, + timestamp=message.timestamp, + uuid="", + ) + effective_type = "user" else: message_content = message.message.content # type: ignore + meta = create_meta(message) + effective_type = message_type # Track sidechain status for user messages # (sidechain user text is skipped to avoid duplicate Task prompts) - is_sidechain_user = message_type == MessageType.USER and getattr( - message, "isSidechain", False - ) + is_sidechain_user = message_type == MessageType.USER and meta.is_sidechain # Chunk content: regular items (text/image) accumulate, special items (tool/thinking) separate if isinstance(message_content, list): @@ -1652,26 +1662,8 @@ def _render_messages( if not chunks: continue - # Create meta once for all chunks from this message - if isinstance(message, BaseTranscriptEntry): - meta = create_meta(message) - else: - # QueueOperationTranscriptEntry has limited fields - meta = MessageMeta( - session_id=getattr(message, "sessionId", ""), - timestamp=getattr(message, "timestamp", ""), - uuid="", # QueueOperationTranscriptEntry has no uuid - ) - - # Determine effective_type for dispatching to user/assistant parsers - # (queue-operation 'remove' messages are treated as user messages) - if isinstance(message, QueueOperationTranscriptEntry): - effective_type = "user" - else: - effective_type = message_type - # Get session info - session_id = getattr(message, "sessionId", "unknown") + session_id = meta.session_id or "unknown" session_summary = sessions.get(session_id, {}).get("summary") # Add session header if this is a new session @@ -1748,7 +1740,7 @@ def _render_messages( meta, chunk, # Pass the chunk items chunk_text, # Pre-extracted text for pattern detection - is_slash_command=getattr(message, "isMeta", False), + is_slash_command=meta.is_meta, ) elif effective_type == "assistant": content_model = create_assistant_message(meta, chunk) @@ -1822,7 +1814,7 @@ def _render_messages( # Generate unique UUID for this tool message # Use tool_use_id if available, otherwise fall back to msg UUID + index - message_uuid = getattr(message, "uuid", "no-uuid") + message_uuid = meta.uuid or "no-uuid" tool_uuid = ( tool_result.tool_use_id if tool_result.tool_use_id From ec0d803e99b9925a592b3c8b029a760fbff9decd Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 24 Dec 2025 11:35:49 +0100 Subject: [PATCH 31/57] Consolidate sidechain deduplication into tree-based cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _cleanup_sidechain_duplicates() for tree-based dedup after build - Remove is_sidechain_user early-skip logic from _render_messages - Remove pending_dedup from ToolItemResult and tool_factory - Add _normalize_for_dedup() to strip agentId resume lines before matching - Update _extract_task_result_text() to use normalization The deduplication now happens on the final tree structure: - First sidechain UserTextMessage (Task input duplicate) is removed - Last sidechain AssistantTextMessage matching Task result is replaced with DedupNoticeMessage pointing to the result 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 19 --- claude_code_log/renderer.py | 189 +++++++++++++++------- 2 files changed, 131 insertions(+), 77 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 3e2edfa6..8b6e5384 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -254,7 +254,6 @@ class ToolItemResult: 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 @@ -344,23 +343,6 @@ def create_tool_result_message( 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 "" @@ -371,6 +353,5 @@ def create_tool_result_message( 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, ) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 6c057df7..a9a9e074 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Render Claude transcript data to HTML format.""" +import re import time from dataclasses import dataclass from pathlib import Path @@ -31,6 +32,7 @@ SessionHeaderMessage, SlashCommandMessage, SystemMessage, + ToolResultMessage, UnknownMessage, UserSlashCommandMessage, UserSteeringMessage, @@ -519,6 +521,12 @@ def generate_template_messages( with log_timing("Build message tree", t_start): root_messages = _build_message_tree(template_messages) + # Clean up sidechain duplicates on the tree structure + # - Remove first UserTextMessage (duplicate of Task input prompt) + # - Replace last AssistantTextMessage (duplicate of Task output) with DedupNotice + with log_timing("Cleanup sidechain duplicates", t_start): + _cleanup_sidechain_duplicates(root_messages) + return root_messages, session_nav @@ -984,11 +992,12 @@ def _get_message_hierarchy_level(msg: TemplateMessage) -> int: - Level 1: User messages - Level 2: System commands/errors, Assistant, Thinking - Level 3: Tool use/result, System info/warning (nested under assistant) - - Level 4: Sidechain assistant/thinking (nested under Task tool result) + - Level 4: Sidechain user/assistant/thinking (nested under Task tool result) - Level 5: Sidechain tools (nested under sidechain assistant) - Note: Sidechain user messages (Sub-assistant prompts) are now skipped entirely - since they duplicate the Task tool input prompt. + Note: Sidechain user messages (duplicate of Task input prompt) and the last + sidechain assistant (duplicate of Task output) are cleaned up from the tree + by _cleanup_sidechain_duplicates after tree building. Returns: Integer hierarchy level (1-5, session headers are 0) @@ -996,10 +1005,9 @@ def _get_message_hierarchy_level(msg: TemplateMessage) -> int: msg_type = msg.type is_sidechain = msg.is_sidechain - # User messages at level 1 (under session) - # Note: sidechain user messages are skipped before reaching this function - if msg_type == "user" and not is_sidechain: - return 1 + # User messages at level 1 (under session), level 4 for sidechain + if msg_type == "user": + return 4 if is_sidechain else 1 # System info/warning at level 3 (tool-related, e.g., hook notifications) # Get level from SystemMessage if available @@ -1182,6 +1190,117 @@ def _build_message_tree(messages: list[TemplateMessage]) -> list[TemplateMessage return root_messages +# Pattern to match agentId lines added to Task results for resume functionality +# e.g., "agentId: a7c9965 (for resuming to continue this agent's work if needed)" +_AGENT_ID_LINE_PATTERN = re.compile(r"\n*agentId:\s*\w+\s*\([^)]*\)\s*$", re.IGNORECASE) + + +def _normalize_for_dedup(text: str) -> str: + """Normalize text for deduplication matching. + + Strips trailing agentId lines that may be added to Task results + but not present in the sidechain assistant's final message. + """ + return _AGENT_ID_LINE_PATTERN.sub("", text).strip() + + +def _extract_task_result_text(tool_result_message: ToolResultMessage) -> Optional[str]: + """Extract text content from a Task tool result for deduplication matching. + + Args: + tool_result_message: The ToolResultMessage containing Task output + + Returns: + The extracted text content (normalized), or None if extraction fails + """ + # Get the ToolResultContent from the output + output = tool_result_message.output + if not isinstance(output, ToolResultContent): + return None + + content = output.content + if isinstance(content, str): + text = content.strip() if content else None + return _normalize_for_dedup(text) if text else None + + # Handle list of dicts (tool result format) + content_parts: list[str] = [] + for item in content: + text_val = item.get("text", "") + if isinstance(text_val, str): + content_parts.append(text_val) + result = "\n".join(content_parts).strip() + return _normalize_for_dedup(result) if result else None + + +def _cleanup_sidechain_duplicates(root_messages: list[TemplateMessage]) -> None: + """Clean up duplicate content in sidechains after tree is built. + + For each Task tool_result with sidechain children: + - Remove the first UserTextMessage (duplicate of Task input prompt) + - Replace the last AssistantTextMessage matching Task result with a DedupNotice + + Matching uses raw_text_content (set during _render_messages) and Task result + text extracted via _extract_task_result_text(). + + Args: + root_messages: List of root messages with children populated + """ + + def process_message(message: TemplateMessage) -> None: + """Recursively process a message and its children.""" + # Recursively process children first (depth-first) + for child in message.children: + process_message(child) + + # Check if this is a Task tool_result with sidechain children + if not ( + message.type == "tool_result" + and isinstance(message.content, ToolResultMessage) + and message.content.tool_name == "Task" + and message.children + ): + return + + children = message.children + + # Remove first sidechain UserTextMessage (duplicate of Task input prompt) + if children and children[0].type == "user" and children[0].is_sidechain: + children.pop(0) + + # Find and replace last sidechain AssistantTextMessage matching Task output + task_result_text = _extract_task_result_text(message.content) + if not task_result_text: + return + + for i in range(len(children) - 1, -1, -1): + child = children[i] + child_text = ( + _normalize_for_dedup(child.raw_text_content) + if child.raw_text_content + else None + ) + if ( + child.type == "assistant" + and child.is_sidechain + and child_text + and child_text == task_result_text + ): + # Replace with dedup notice pointing to the Task result + child.content = DedupNoticeMessage( + MessageMeta.empty(), + notice_text="Task summary — see result above", + target_uuid=message.uuid, + target_message_id=message.message_id, + original_text=child_text, + ) + child.raw_text_content = None + break + + for root in root_messages: + process_message(root) + + # -- Message Reordering ------------------------------------------------------- @@ -1256,9 +1375,9 @@ def _reorder_sidechain_template_messages( order based on when each agent finishes. This function reorders messages so that each sidechain's messages appear right after the Task result that references them. - This function also handles deduplication: the last sidechain assistant message - typically contains the same content as the Task result, so we replace it with - a forward link to avoid showing the same content twice. + Note: Deduplication of sidechain content (first user message = Task input, + last assistant message = Task output) is handled later by _cleanup_sidechain_duplicates + after the tree structure is built. This must be called AFTER _reorder_paired_messages, since that function moves tool_results next to their tool_uses, which changes where the agentId-bearing @@ -1291,7 +1410,6 @@ def _reorder_sidechain_template_messages( return messages # Second pass: insert sidechains after their Task result messages - # Also perform deduplication of sidechain assistants vs Task results result: list[TemplateMessage] = [] used_agents: set[str] = set() @@ -1311,40 +1429,9 @@ def _reorder_sidechain_template_messages( 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 - # that matches the Task result content - task_result_content = ( - message.raw_text_content.strip() if message.raw_text_content else None - ) - 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 = ( - sidechain_msg.raw_text_content.strip() - if sidechain_msg.raw_text_content - else None - ) - if ( - sidechain_msg.type == MessageType.ASSISTANT - and sidechain_text - and sidechain_text == task_result_content - ): - # Replace with note pointing to the Task result - sidechain_msg.content = DedupNoticeMessage( - MessageMeta.empty(), - notice_text="Task summary — see result above", - target_uuid=message.uuid, - original_text=sidechain_text, - ) - # 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) + result.extend(sidechain_map[agent_id]) used_agents.add(agent_id) # Append any sidechains that weren't matched (shouldn't happen normally) @@ -1641,10 +1728,6 @@ def _render_messages( meta = create_meta(message) effective_type = message_type - # Track sidechain status for user messages - # (sidechain user text is skipped to avoid duplicate Task prompts) - is_sidechain_user = message_type == MessageType.USER and meta.is_sidechain - # Chunk content: regular items (text/image) accumulate, special items (tool/thinking) separate if isinstance(message_content, list): chunks = chunk_message_content(message_content) # type: ignore[arg-type] @@ -1724,11 +1807,6 @@ def _render_messages( for chunk in chunks: # Regular chunk: list of text/image items if isinstance(chunk, list): - # Skip text chunks for sidechain user messages - # (prompts duplicate Task result; filtering already done in pass 1) - if is_sidechain_user: - continue - # Extract text for pattern detection chunk_text = extract_text_content(chunk) @@ -1778,7 +1856,7 @@ def _render_messages( token_usage=chunk_token_usage, ) - # Store raw text content for potential future use + # Store raw text for sidechain deduplication matching template_message.raw_text_content = chunk_text template_messages.append(template_message) @@ -1832,11 +1910,6 @@ def _render_messages( uuid=tool_uuid, ) - # Store raw text for Task result deduplication - # (handled later in _reorder_sidechain_template_messages) - if tool_result.pending_dedup is not None: - tool_template_message.raw_text_content = tool_result.pending_dedup - template_messages.append(tool_template_message) return template_messages From aa138da55734d22549d667beaf1a822b986b9a63 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 24 Dec 2025 11:50:41 +0100 Subject: [PATCH 32/57] Move raw_text_content from TemplateMessage to MessageContent subclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add raw_text_content field to UserTextMessage and AssistantTextMessage - Update create_user_message to set raw_text_content from text_content param - Update create_assistant_message to accept and set text_content param - Update _cleanup_sidechain_duplicates to access via content.raw_text_content - Remove raw_text_content from TemplateMessage This moves the field closer to the data it represents, preparing for TemplateMessage simplification. Simple renderers can now use raw_text_content directly instead of iterating through items. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/assistant_factory.py | 6 ++++++ claude_code_log/factories/user_factory.py | 13 +++++++++++-- claude_code_log/models.py | 6 ++++++ claude_code_log/renderer.py | 15 +++------------ 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/claude_code_log/factories/assistant_factory.py b/claude_code_log/factories/assistant_factory.py index c2db4531..90cf1909 100644 --- a/claude_code_log/factories/assistant_factory.py +++ b/claude_code_log/factories/assistant_factory.py @@ -12,6 +12,7 @@ AssistantTextMessage, ContentItem, MessageMeta, + TextContent, ThinkingContent, ThinkingMessage, ) @@ -40,9 +41,14 @@ def create_assistant_message( # Create AssistantTextMessage directly from items # (empty text already filtered by chunk_message_content) if items: + # Extract text content from items for dedup matching and simple renderers + text_content = "\n".join( + item.text for item in items if isinstance(item, TextContent) + ) return AssistantTextMessage( meta, items=items, # type: ignore[arg-type] + raw_text_content=text_content if text_content else None, ) return None diff --git a/claude_code_log/factories/user_factory.py b/claude_code_log/factories/user_factory.py index 36e8ad68..22b30f66 100644 --- a/claude_code_log/factories/user_factory.py +++ b/claude_code_log/factories/user_factory.py @@ -464,5 +464,14 @@ def create_user_message( # Duck-typed image content - convert to our Pydantic model items.append(ImageContent.model_validate(item.model_dump())) # type: ignore[union-attr] - # Return UserTextMessage with items list - return UserTextMessage(items=items, meta=meta) + # Extract text content from items for dedup matching and simple renderers + raw_text_content = "\n".join( + item.text for item in items if isinstance(item, TextContent) + ) + + # Return UserTextMessage with items list and cached raw text + return UserTextMessage( + items=items, + raw_text_content=raw_text_content if raw_text_content else None, + meta=meta, + ) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index c3c06376..9aefaf51 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -455,6 +455,9 @@ class UserTextMessage(MessageContent): TextContent | ImageContent | IdeNotificationContent ] = field(default_factory=list) + # Cached raw text extracted from items (for dedup matching, simple renderers) + raw_text_content: Optional[str] = None + @property def message_type(self) -> str: return "user" @@ -501,6 +504,9 @@ class AssistantTextMessage(MessageContent): TextContent | ImageContent ] = field(default_factory=list) + # Cached raw text extracted from items (for dedup matching, simple renderers) + raw_text_content: Optional[str] = None + @property def message_type(self) -> str: return "assistant" diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index a9a9e074..0e11390b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -109,9 +109,6 @@ def __init__( # uuid can differ from meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") self.uuid = uuid if uuid is not None else meta.uuid - # Raw text content for deduplication (sidechain assistants vs Task results) - self.raw_text_content: Optional[str] = None - # Fold/unfold counts self.immediate_children_count = 0 # Direct children only self.total_descendants_count = 0 # All descendants recursively @@ -1275,11 +1272,9 @@ def process_message(message: TemplateMessage) -> None: for i in range(len(children) - 1, -1, -1): child = children[i] - child_text = ( - _normalize_for_dedup(child.raw_text_content) - if child.raw_text_content - else None - ) + # Get raw_text_content from content (UserTextMessage/AssistantTextMessage) + child_raw = getattr(child.content, "raw_text_content", None) + child_text = _normalize_for_dedup(child_raw) if child_raw else None if ( child.type == "assistant" and child.is_sidechain @@ -1294,7 +1289,6 @@ def process_message(message: TemplateMessage) -> None: target_message_id=message.message_id, original_text=child_text, ) - child.raw_text_content = None break for root in root_messages: @@ -1856,9 +1850,6 @@ def _render_messages( token_usage=chunk_token_usage, ) - # Store raw text for sidechain deduplication matching - template_message.raw_text_content = chunk_text - template_messages.append(template_message) else: From 8b7c10f262d5b7c9dc5488cecd8f491c663cacc9 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 24 Dec 2025 17:44:25 +0100 Subject: [PATCH 33/57] Fix sidechain cleanup to adopt orphaned children and add Explore agent test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make _cleanup_sidechain_duplicates check for Task tool_use OR tool_result - Only remove UserTextMessage (not other user types like ToolResultMessage) - Adopt removed message's children to prevent orphaning Level 5 tool messages - Add test_explore_agent_no_user_prompt for assistant-first agent pattern - Fix test_slash_command_css_class to use realistic parentUuid: null for isMeta - Document sidechain hierarchy patterns in TEMPLATE_MESSAGE_CHILDREN.md - Before/after cleanup tree structure - Plan-type vs Explore-type agent patterns - Child adoption mechanism 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 64 +++++++++++++---------- dev-docs/TEMPLATE_MESSAGE_CHILDREN.md | 73 ++++++++++++++++++++++++++- test/test_phase8_message_variants.py | 65 ++++++++++-------------- test/test_sidechain_agents.py | 57 +++++++++++++++++++++ 4 files changed, 195 insertions(+), 64 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 0e11390b..0da390c3 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -5,7 +5,7 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Optional, Tuple, TYPE_CHECKING +from typing import Any, Callable, Optional, Tuple, TYPE_CHECKING, cast if TYPE_CHECKING: from .cache import CacheManager @@ -33,6 +33,7 @@ SlashCommandMessage, SystemMessage, ToolResultMessage, + ToolUseMessage, UnknownMessage, UserSlashCommandMessage, UserSteeringMessage, @@ -1233,12 +1234,12 @@ def _extract_task_result_text(tool_result_message: ToolResultMessage) -> Optiona def _cleanup_sidechain_duplicates(root_messages: list[TemplateMessage]) -> None: """Clean up duplicate content in sidechains after tree is built. - For each Task tool_result with sidechain children: + For each Task tool_use or tool_result with sidechain children: - Remove the first UserTextMessage (duplicate of Task input prompt) - - Replace the last AssistantTextMessage matching Task result with a DedupNotice + - For tool_result: Replace last AssistantTextMessage matching result with DedupNotice - Matching uses raw_text_content (set during _render_messages) and Task result - text extracted via _extract_task_result_text(). + Sidechain messages can be children of either tool_use or tool_result depending + on timestamp order - tool_use during execution, tool_result after completion. Args: root_messages: List of root messages with children populated @@ -1250,23 +1251,44 @@ def process_message(message: TemplateMessage) -> None: for child in message.children: process_message(child) - # Check if this is a Task tool_result with sidechain children - if not ( + # Check if this is a Task tool_use or tool_result with sidechain children + is_task_tool_use = ( + message.type == "tool_use" + and isinstance(message.content, ToolUseMessage) + and message.content.tool_name == "Task" + ) + is_task_tool_result = ( message.type == "tool_result" and isinstance(message.content, ToolResultMessage) and message.content.tool_name == "Task" - and message.children - ): + ) + + if not ((is_task_tool_use or is_task_tool_result) and message.children): return children = message.children # Remove first sidechain UserTextMessage (duplicate of Task input prompt) - if children and children[0].type == "user" and children[0].is_sidechain: - children.pop(0) + # Must be specifically UserTextMessage, not ToolResultMessage or other user types + # When removing, adopt its children to preserve sidechain tool messages + if ( + children + and children[0].is_sidechain + and isinstance(children[0].content, UserTextMessage) + ): + removed = children.pop(0) + # Adopt orphaned children (tool_use/tool_result from sidechain) + if removed.children: + # Insert at beginning to maintain order + children[:0] = removed.children + + # For tool_result only: replace last matching AssistantTextMessage with dedup + if not is_task_tool_result: + return - # Find and replace last sidechain AssistantTextMessage matching Task output - task_result_text = _extract_task_result_text(message.content) + task_result_text = _extract_task_result_text( + cast(ToolResultMessage, message.content) + ) if not task_result_text: return @@ -1461,10 +1483,12 @@ def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: - 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. + Note: Sidechain user prompts (duplicates of Task input) are removed later + by _cleanup_sidechain_duplicates after tree building. + Args: messages: List of transcript entries to filter @@ -1474,8 +1498,6 @@ def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: filtered: list[TranscriptEntry] = [] for message in messages: - message_type = message.type - # Skip summary messages if isinstance(message, SummaryTranscriptEntry): continue @@ -1516,16 +1538,6 @@ def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: 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): - 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) diff --git a/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md index b1acf510..6a4c142b 100644 --- a/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md +++ b/dev-docs/TEMPLATE_MESSAGE_CHILDREN.md @@ -25,12 +25,83 @@ 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 4: Sidechain user/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. +### Sidechain Hierarchy Details (2025-12-24) + +Sidechain messages come from **Task tool** invocations (subagent spawning). Key findings from investigating real session data: + +#### Where Sidechain Children Attach + +Due to **pair reordering** (tool_result is moved right after its corresponding tool_use), sidechain messages become children of the **Task tool_result**, not the tool_use. + +#### Agent Type Patterns + +Real session data shows two distinct patterns depending on agent type: + +**Plan-type agents** (e.g., `-Users-dain-workspace-coderabbit-*`): +- Start with a **user prompt** (`UserTextMessage` with `isSidechain=true`, `parentUuid=null`) +- This user prompt duplicates the Task input + +Initial tree structure (before cleanup): +``` +tool_use (Task) ← 0 children (pair reordering moves tool_result here) +tool_result (Task) ← sidechain messages become children here + └─ user(sc): UserTextMessage ← Level 4: duplicate of Task input + └─ assistant(sc) ← Level 4: parented to user(sc) + └─ tool_use(sc) ← Level 5: parented to user(sc) + └─ tool_result(sc) ← Level 5 +``` + +After cleanup (user prompt removed, children adopted): +``` +tool_result (Task) + └─ assistant(sc) ← Now direct child of tool_result + └─ tool_use(sc) ← Adopted from removed user(sc) + └─ tool_result(sc) +``` + +**Explore-type agents** (e.g., `-src-deep-manifest`): +- Start directly with **assistant** (no user prompt) +- No cleanup needed for the first message + +``` +tool_result (Task) + └─ assistant(sc): AssistantTextMessage ← First child, kept as-is + └─ tool_use(sc) + └─ tool_result(sc) +``` + +#### Child Adoption During Cleanup + +When `_cleanup_sidechain_duplicates()` removes a UserTextMessage (the duplicate prompt), it must **adopt the removed message's children** to prevent orphaning Level 5 tool messages: + +```python +# In _cleanup_sidechain_duplicates() +if ( + children + and children[0].is_sidechain + and isinstance(children[0].content, UserTextMessage) +): + removed = children.pop(0) + # Adopt orphaned children (tool_use/tool_result from sidechain) + if removed.children: + children[:0] = removed.children +``` + +Without this adoption, the sidechain tool messages would be lost from the tree. + +#### Key Insight + +The hierarchy level is determined by message **type**, not by `parentUuid`. A sidechain user message (`parentUuid=null`) still appears at Level 4 because: +1. It has `isSidechain=true` +2. Its effective parent is determined by the Task tool_result (found via timestamp/session matching) +3. The tree-building algorithm correctly places it as a child of the Task tool_result + ### Template Rendering (current) - Single `{% for message in messages %}` loop over flattened list - Ancestry rendered as CSS classes for JavaScript DOM queries diff --git a/test/test_phase8_message_variants.py b/test/test_phase8_message_variants.py index 3d4da469..2973364e 100644 --- a/test/test_phase8_message_variants.py +++ b/test/test_phase8_message_variants.py @@ -21,31 +21,14 @@ class TestSlashCommandRendering: def test_slash_command_css_class(self): """Test that isMeta=True user messages get 'slash-command' CSS class.""" - # Parent user message (normal) - parent_message = { - "type": "user", - "timestamp": "2025-06-11T22:45:17.436Z", - "parentUuid": None, - "isSidechain": False, - "isMeta": False, - "userType": "human", - "cwd": "/tmp", - "sessionId": "test_session", - "version": "1.0.0", - "uuid": "parent_001", - "message": { - "role": "user", - "content": [{"type": "text", "text": "/review"}], - }, - } - # Expanded slash command prompt (isMeta=True) + # In real transcripts, slash command expansions are standalone messages slash_command_message = { "type": "user", - "timestamp": "2025-06-11T22:45:17.436Z", # Same timestamp as parent - "parentUuid": "parent_001", + "timestamp": "2025-06-11T22:45:17.436Z", + "parentUuid": None, "isSidechain": False, - "isMeta": True, # This is the key flag + "isMeta": True, # This is the key flag for slash command expanded prompts "userType": "external", "cwd": "/tmp", "sessionId": "test_session", @@ -63,14 +46,13 @@ def test_slash_command_css_class(self): } with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: - f.write(json.dumps(parent_message) + "\n") f.write(json.dumps(slash_command_message) + "\n") f.flush() test_file_path = Path(f.name) try: messages = load_transcript(test_file_path) - assert len(messages) == 2 + assert len(messages) == 1 html = generate_html(messages, "Test Slash Command") @@ -93,7 +75,12 @@ def test_slash_command_css_class(self): test_file_path.unlink() def test_slash_command_sidechain(self): - """Test slash command in sidechain context.""" + """Test slash command in sidechain context renders correctly. + + Standalone sidechain user messages (not children of Task) are rendered, + since they're not duplicates of Task input prompts. Only the first + UserTextMessage child of a Task tool_result is removed as duplicate. + """ slash_command_sidechain = { "type": "user", "timestamp": "2025-06-11T22:45:17.436Z", @@ -126,12 +113,12 @@ def test_slash_command_sidechain(self): messages = load_transcript(test_file_path) html = generate_html(messages, "Test Sidechain Slash Command") - # Sidechain user messages without tool results are skipped during filtering - # (see _filter_messages in renderer.py). Even with isMeta=True, they don't - # contain tool results so they are not rendered. - assert "Sub-agent Slash Command" not in html, ( - "Sidechain user messages without tool results should be skipped" + # Standalone sidechain user messages (not Task children) are rendered + assert "Sub-agent Slash Command" in html, ( + "Standalone sidechain user messages should be rendered" ) + # Should have sidechain CSS class + assert "sidechain" in html finally: test_file_path.unlink() @@ -344,7 +331,12 @@ def test_tool_result_error_sidechain(self): test_file_path.unlink() def test_user_compacted_sidechain(self): - """Test user message with compacted and sidechain modifiers.""" + """Test user message with compacted and sidechain modifiers renders. + + Standalone sidechain user messages (not children of Task) are rendered, + since they're not duplicates of Task input prompts. Only the first + UserTextMessage child of a Task tool_result is removed as duplicate. + """ # Compacted messages have specific structure compacted_message = { "type": "user", @@ -377,14 +369,13 @@ def test_user_compacted_sidechain(self): messages = load_transcript(test_file_path) html = generate_html(messages, "Test Compacted Sidechain") - # Sidechain user messages are skipped (duplicate of Task prompt input) - # Verify the raw content is not rendered - assert "context-messages" not in html, ( - "Sidechain user messages should be skipped" - ) - assert "[Compacted conversation]" not in html, ( - "Sidechain user message content should not be rendered" + # Standalone sidechain compacted messages are rendered + # They get special formatting as CompactedSummaryMessage + assert "Compacted conversation" in html, ( + "Standalone sidechain compacted messages should be rendered" ) + # Should have sidechain CSS class + assert "sidechain" in html finally: test_file_path.unlink() diff --git a/test/test_sidechain_agents.py b/test/test_sidechain_agents.py index 880cf028..b79e787e 100644 --- a/test/test_sidechain_agents.py +++ b/test/test_sidechain_agents.py @@ -197,6 +197,63 @@ def test_sidechain_tool_results_rendered(): ), "Sidechain user prompts should be skipped" +def test_explore_agent_no_user_prompt(): + """Test Explore-type agents that start with assistant (no user prompt). + + Real-world pattern from -src-deep-manifest project: Explore agents don't have + a user prompt message at the start - they begin directly with assistant messages. + This differs from Plan-type agents (like in coderabbit) which start with a user + prompt that duplicates the Task input. + + The cleanup should handle both patterns correctly: + - Plan agents: Remove first UserTextMessage (duplicate prompt) + - Explore agents: No UserTextMessage to remove, just process normally + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # Create main session with Task tool_use (Explore type) + main_file = tmpdir_path / "main.jsonl" + main_file.write_text( + # User request + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"text","text":"How does the ZIP processing work?"}]},"uuid":"u-0","timestamp":"2025-01-15T15:24:45.000Z"}\n' + # Assistant invokes Task (Explore subagent) + '{"parentUuid":"u-0","isSidechain":false,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_main","type":"message","role":"assistant","content":[{"type":"tool_use","id":"task-explore","name":"Task","input":{"prompt":"Explore ZIP processing","subagent_type":"Explore"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":50}},"requestId":"req_main","type":"assistant","uuid":"a-0","timestamp":"2025-01-15T15:24:47.000Z"}\n' + # Task result (triggers agent loading) + '{"parentUuid":"a-0","isSidechain":false,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"task-explore","content":"ZIP files are processed recursively..."}]},"uuid":"u-1","timestamp":"2025-01-15T15:25:00.000Z","toolUseResult":{"agentId":"explore-agent","content":"ZIP files are processed recursively..."},"agentId":"explore-agent"}\n' + ) + + # Create Explore-type agent file: starts with ASSISTANT (no user prompt) + # This matches real data from -src-deep-manifest/agent-c8d9b115.jsonl + agent_file = tmpdir_path / "agent-explore-agent.jsonl" + agent_file.write_text( + # First message is assistant (NOT user) - Explore agents don't echo the prompt + '{"parentUuid":null,"isSidechain":true,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","agentId":"explore-agent","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_explore_1","type":"message","role":"assistant","content":[{"type":"text","text":"Let me search for ZIP-related code."},{"type":"tool_use","id":"grep-1","name":"Grep","input":{"pattern":"zip"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":50,"output_tokens":30}},"requestId":"req_explore_1","type":"assistant","uuid":"explore-a-0","timestamp":"2025-01-15T15:24:51.000Z"}\n' + # User provides Grep result + '{"parentUuid":"explore-a-0","isSidechain":true,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","agentId":"explore-agent","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"grep-1","content":"deep_manifest.py:42: def process_zip()"}]},"uuid":"explore-u-0","timestamp":"2025-01-15T15:24:52.000Z"}\n' + # Assistant continues with another tool + '{"parentUuid":"explore-u-0","isSidechain":true,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","agentId":"explore-agent","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_explore_2","type":"message","role":"assistant","content":[{"type":"text","text":"Found it. Let me read the file."},{"type":"tool_use","id":"read-1","name":"Read","input":{"file_path":"deep_manifest.py"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":80,"output_tokens":40}},"requestId":"req_explore_2","type":"assistant","uuid":"explore-a-1","timestamp":"2025-01-15T15:24:53.000Z"}\n' + # User provides Read result + '{"parentUuid":"explore-a-1","isSidechain":true,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","agentId":"explore-agent","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"read-1","content":"def process_zip(path):\\n with zipfile.ZipFile(path) as zf:\\n ..."}]},"uuid":"explore-u-1","timestamp":"2025-01-15T15:24:54.000Z"}\n' + # Final assistant summary + '{"parentUuid":"explore-u-1","isSidechain":true,"userType":"external","cwd":"/workspace","sessionId":"test-explore","version":"2.0.46","gitBranch":"main","agentId":"explore-agent","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_explore_final","type":"message","role":"assistant","content":[{"type":"text","text":"ZIP files are processed recursively..."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":100,"output_tokens":50}},"requestId":"req_explore_final","type":"assistant","uuid":"explore-a-2","timestamp":"2025-01-15T15:24:58.000Z"}\n' + ) + + messages = load_transcript(main_file) + html = generate_html(messages, title="Test Explore Agent") + + # Verify sidechain tool results are rendered (Grep and Read results) + assert "deep_manifest.py:42" in html, "Grep tool result should be rendered" + assert "def process_zip" in html, "Read tool result should be rendered" + + # Verify assistant messages are rendered + assert "Let me search for ZIP-related code" in html + assert "Found it. Let me read the file" in html + + # The final assistant message matches Task result, should become dedup notice + # (but the intermediate messages should still be visible) + + def test_multiple_agent_invocations(): """Test handling of multiple Task invocations in same session.""" with tempfile.TemporaryDirectory() as tmpdir: From 20132b72ff25cdb22774b6125b50d064d591394b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 14:56:50 +0100 Subject: [PATCH 34/57] Cleanup: remove Anthropic API references and obsolete planning docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify ImageContent docstring to just "Image content" - Remove PLAN_PHASE12.md (completed) - Remove dev-docs/REMOVE_ANTHROPIC_TYPES.md (completed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- PLAN_PHASE12.md | 345 ----------------------------- claude_code_log/models.py | 2 +- dev-docs/REMOVE_ANTHROPIC_TYPES.md | 76 ------- 3 files changed, 1 insertion(+), 422 deletions(-) delete mode 100644 PLAN_PHASE12.md delete mode 100644 dev-docs/REMOVE_ANTHROPIC_TYPES.md diff --git a/PLAN_PHASE12.md b/PLAN_PHASE12.md deleted file mode 100644 index 1dd2836d..00000000 --- a/PLAN_PHASE12.md +++ /dev/null @@ -1,345 +0,0 @@ -# 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 #} -
- -{# After #} -
-``` - -### Phase 12e: Remove css_class - -Once all references use modifiers: -1. Remove `css_class` parameter from `TemplateMessage.__init__` -2. Remove `self.css_class = css_class` -3. Clean up all `css_class=...` at creation sites -4. Update tests to use modifiers - -## Files Changed - -| File | Changes | -|------|---------| -| `models.py` | Add `MessageModifiers` dataclass | -| `renderer.py` | Update TemplateMessage, populate modifiers, update hierarchy logic | -| `html_renderer.py` | New file with HTML utilities and css_class_from_message | -| `templates/transcript.html` | Use css_class_from_message filter | -| `test_*.py` | Update tests to use modifiers | - -## Testing Strategy - -1. **Snapshot tests**: Run after each phase to verify HTML output unchanged -2. **Unit tests for css_class_from_message**: Verify it produces same strings -3. **Unit tests for modifiers**: Test each modifier flag -4. **Integration tests**: Full render with real transcripts - -## Commit Plan - -1. `Add MessageModifiers dataclass to models.py` (12a) -2. `Add modifiers field to TemplateMessage` (12a) -3. `Populate modifiers in message processing` (12b part 1) -4. `Update hierarchy logic to use modifiers` (12b part 2) -5. `Create html_renderer.py with css_class_from_message` (12c) -6. `Move escape_html and render_markdown to html_renderer` (12c) -7. `Update template to use css_class_from_message` (12d) -8. `Remove css_class field from TemplateMessage` (12e) - -## Risk Assessment - -- **Low risk**: MessageModifiers is additive, doesn't break existing code -- **Medium risk**: Moving functions to html_renderer.py requires import updates -- **High risk**: Template changes and css_class removal need careful testing - -## Estimated Scope - -- Phase 12a: ~30 lines added to models.py, ~10 lines to renderer.py -- Phase 12b: ~50 modifications across renderer.py -- Phase 12c: ~200 lines new file, ~200 lines moved from renderer.py -- Phase 12d: ~10 lines template changes -- Phase 12e: ~20 lines removed - -Total: Moderate refactoring, ~5-8 commits diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 9aefaf51..ab200110 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -428,7 +428,7 @@ class ImageSource(BaseModel): class ImageContent(BaseModel): - """Image content from the Anthropic API. + """Image content. This represents an image within a content array, not a standalone message. Images are always part of UserTextMessage.items or AssistantTextMessage.items. diff --git a/dev-docs/REMOVE_ANTHROPIC_TYPES.md b/dev-docs/REMOVE_ANTHROPIC_TYPES.md deleted file mode 100644 index 1b7f96b1..00000000 --- a/dev-docs/REMOVE_ANTHROPIC_TYPES.md +++ /dev/null @@ -1,76 +0,0 @@ -# Remove Anthropic Types Dependency - -## Current Usage - -### Imports - -| Import | File | Purpose | -|--------|------|---------| -| `AnthropicMessage` | models.py, parser.py | Validate message compatibility | -| `AnthropicUsage` | models.py, parser.py | Convert usage info | -| `StopReason` | models.py | Type alias | - -### Methods - -| Method | Location | Used? | -|--------|----------|-------| -| `to_anthropic_usage()` | UsageInfo (models.py:711) | **Never** | -| `from_anthropic_usage()` | UsageInfo (models.py:725) | Yes, in `normalize_usage_info()` | -| `from_anthropic_message()` | AssistantMessage (models.py:808) | **Never** | - -### Call Sites - -| Call | Location | Purpose | Needed? | -|------|----------|---------|---------| -| `AnthropicMessage.model_validate()` | parser.py:933 | Validate JSONL data is compatible | **No** - result unused | -| `AnthropicUsage.model_validate()` | parser.py:731 | Parse usage dict as Anthropic type | **No** - can use UsageInfo directly | -| `UsageInfo.from_anthropic_usage()` | parser.py:725, 732 | Convert Anthropic Usage to ours | **Partially** - simplify | - -## Simplification Plan - -### Phase 1: Remove dead code -1. Remove `to_anthropic_usage()` - never used -2. Remove `from_anthropic_message()` - never used -3. Remove `AnthropicMessage.model_validate()` validation in parser.py - no-op -4. Remove `StopReason` import - just use `Optional[str]` - -### Phase 2: Simplify usage parsing -Replace the Anthropic-aware `normalize_usage_info()` with direct dict-to-UsageInfo conversion: -```python -def normalize_usage_info(usage_data: Any) -> Optional[UsageInfo]: - if usage_data is None: - return None - if isinstance(usage_data, UsageInfo): - return usage_data - if isinstance(usage_data, dict): - return UsageInfo.model_validate(usage_data) - # Handle object-like access for backwards compatibility - return UsageInfo( - input_tokens=getattr(usage_data, "input_tokens", None), - ... - ) -``` - -### Phase 3: Remove Anthropic imports -After Phase 1-2, remove from models.py and parser.py: -- `from anthropic.types import Message as AnthropicMessage` -- `from anthropic.types import Usage as AnthropicUsage` -- `from anthropic.types import StopReason` - -## Benefits - -1. **Simpler dependency** - Don't need anthropic SDK types for parsing our own JSONL -2. **Clearer ownership** - Our models are the canonical types, not wrappers -3. **Easier maintenance** - No need to track Anthropic SDK type changes -4. **Smaller models.py** - Less code, clearer structure - -## Open Questions - -1. **Was there ever a use case for `from_anthropic_message()`?** - - Possibly for direct SDK integration, but we only parse JSONL files - -2. **Why validate against `AnthropicMessage`?** - - Historical artifact from when we considered using SDK types directly - -3. **Could Anthropic types return in content arrays?** - - We already removed `ContentBlock` from `ContentItem` - no SDK types in content From 3f5f61478f434bf4218fe47f5eb06aa2193ba732 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 15:10:46 +0100 Subject: [PATCH 35/57] Reorganize models.py: move tool message/input/output sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move ToolResultMessage and ToolUseMessage to new "Tool Message Models" section before Tool Input Models - Move Tool Output Models section after Tool Input Models - Add "Transcript Content Models" section separator after ToolOutput - Add reference comments at original locations (User and Assistant sections) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 358 ++++++++++++++++++++------------------ 1 file changed, 190 insertions(+), 168 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index ab200110..9f7b5e9d 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -259,42 +259,8 @@ def message_title(self) -> Optional[str]: return "" # Empty title for bash output -@dataclass -class ToolResultMessage(MessageContent): - """Message for tool results with rendering context. - - Wraps ToolResultContent or specialized output with additional context - needed for rendering, such as the associated tool name and file path. - """ - - tool_use_id: str - output: ( - "ToolOutput" # Specialized (ReadOutput, etc.) or generic (ToolResultContent) - ) - 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 - - @property - def message_type(self) -> str: - return "tool_result" - - -@dataclass -class ToolUseMessage(MessageContent): - """Message for tool invocations. - - Wraps ToolUseContent with the parsed input for specialized formatting. - Falls back to the original ToolUseContent when no specialized parser exists. - """ - - input: "ToolInput" # Specialized (BashInput, etc.) or ToolUseContent fallback - tool_use_id: str # From ToolUseContent.id - tool_name: str # From ToolUseContent.name - - @property - def message_type(self) -> str: - return "tool_use" +# Note: ToolResultMessage and ToolUseMessage are defined in the +# "Tool Message Models" section (before Tool Input Models). @dataclass @@ -545,6 +511,10 @@ def message_title(self) -> Optional[str]: return "Thinking" +# Note: ToolUseMessage is also an assistant content type, defined in +# "Tool Message Models" section (before Tool Input Models). + + @dataclass class UnknownMessage(MessageContent): """Content for unknown/unrecognized content types. @@ -560,138 +530,6 @@ def message_type(self) -> str: return "unknown" -# ============================================================================= -# Tool Output Models -# ============================================================================= -# Typed models for tool outputs (symmetric with Tool Input Models). -# These are data containers stored inside ToolResultMessage.output, -# NOT standalone message types (so they don't inherit from MessageContent). - - -@dataclass -class ReadOutput: - """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: - """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: - """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: - """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: - """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: - """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: - """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 - - -# Union of all specialized output types + ToolResultContent as generic fallback -# Note: Uses forward reference for ToolResultContent (defined later with ContentItem types) -ToolOutput = Union[ - ReadOutput, - EditOutput, - # Add more specialized output types as they're implemented: - # WriteOutput, BashOutput, TaskOutput, GlobOutput, GrepOutput - "ToolResultContent", # Generic fallback for unparsed results -] - - # ============================================================================= # Renderer Content Models # ============================================================================= @@ -734,6 +572,51 @@ def message_type(self) -> str: return "dedup_notice" +# ============================================================================= +# Tool Message Models +# ============================================================================= +# High-level message wrappers for tool invocations and results. +# These wrap the specialized Tool Input/Output models for rendering. + + +@dataclass +class ToolResultMessage(MessageContent): + """Message for tool results with rendering context. + + Wraps ToolResultContent or specialized output with additional context + needed for rendering, such as the associated tool name and file path. + """ + + tool_use_id: str + output: ( + "ToolOutput" # Specialized (ReadOutput, etc.) or generic (ToolResultContent) + ) + 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 + + @property + def message_type(self) -> str: + return "tool_result" + + +@dataclass +class ToolUseMessage(MessageContent): + """Message for tool invocations. + + Wraps ToolUseContent with the parsed input for specialized formatting. + Falls back to the original ToolUseContent when no specialized parser exists. + """ + + input: "ToolInput" # Specialized (BashInput, etc.) or ToolUseContent fallback + tool_use_id: str # From ToolUseContent.id + tool_name: str # From ToolUseContent.name + + @property + def message_type(self) -> str: + return "tool_use" + + # ============================================================================= # Tool Input Models # ============================================================================= @@ -901,6 +784,145 @@ class ExitPlanModeInput(BaseModel): ] +# ============================================================================= +# Tool Output Models +# ============================================================================= +# Typed models for tool outputs (symmetric with Tool Input Models). +# These are data containers stored inside ToolResultMessage.output, +# NOT standalone message types (so they don't inherit from MessageContent). + + +@dataclass +class ReadOutput: + """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: + """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: + """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: + """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: + """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: + """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: + """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 + + +# Union of all specialized output types + ToolResultContent as generic fallback +# Note: Uses forward reference for ToolResultContent (defined later with ContentItem types) +ToolOutput = Union[ + ReadOutput, + EditOutput, + # Add more specialized output types as they're implemented: + # WriteOutput, BashOutput, TaskOutput, GlobOutput, GrepOutput + "ToolResultContent", # Generic fallback for unparsed results +] + + +# ============================================================================= +# Transcript Content Models (Pydantic) +# ============================================================================= +# Low-level content types parsed from JSONL transcript entries. +# These are the raw content items that appear in message content arrays. + + class UsageInfo(BaseModel): """Token usage information for tracking API consumption.""" From 604f11c6640d19d45c910c38c89145a103f47fa5 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 15:23:49 +0100 Subject: [PATCH 36/57] Move JSONL Content Models to top of models.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "Transcript Content Models" to "JSONL Content Models" - Move section to right after MessageType enum (start with input types) - Include TextContent, ImageSource, ImageContent in merged section - Remove forward reference quotes for ToolUseContent and ToolResultContent 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 379 +++++++++++++++++++------------------- 1 file changed, 186 insertions(+), 193 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 9f7b5e9d..97d2a2ac 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -45,6 +45,190 @@ class MessageType(str, Enum): SYSTEM_ERROR = "system-error" +# ============================================================================= +# JSONL Content Models (Pydantic) +# ============================================================================= +# Low-level content types parsed from JSONL transcript entries. +# These are defined first as they're the "input" types from transcript files. + + +class TextContent(BaseModel): + """Text content block within a message content array.""" + + type: Literal["text"] + text: str + + +class ImageSource(BaseModel): + """Base64-encoded image source data.""" + + type: Literal["base64"] + media_type: str + data: str + + +class ImageContent(BaseModel): + """Image content. + + This represents an image within a content array, not a standalone message. + Images are always part of UserTextMessage.items or AssistantTextMessage.items. + """ + + type: Literal["image"] + source: ImageSource + + +class UsageInfo(BaseModel): + """Token usage information for tracking API consumption.""" + + 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 + + +class ToolUseContent(BaseModel): + type: Literal["tool_use"] + id: str + name: str + input: dict[str, Any] + + +class ToolResultContent(BaseModel): + type: Literal["tool_result"] + tool_use_id: str + content: Union[str, list[dict[str, Any]]] + is_error: Optional[bool] = None + agentId: Optional[str] = None # Reference to agent file for sub-agent messages + + +class ThinkingContent(BaseModel): + type: Literal["thinking"] + thinking: str + signature: Optional[str] = None + + +# Content item types that appear in message content arrays +ContentItem = Union[ + TextContent, + ToolUseContent, + ToolResultContent, + ThinkingContent, + ImageContent, +] + + +class UserMessageModel(BaseModel): + role: Literal["user"] + content: list[ContentItem] + usage: Optional["UsageInfo"] = ( + None # For type compatibility with AssistantMessageModel + ) + + +class AssistantMessageModel(BaseModel): + """Assistant message model.""" + + id: str + type: Literal["message"] + role: Literal["assistant"] + model: str + content: list[ContentItem] + stop_reason: Optional[str] = None + stop_sequence: Optional[str] = None + usage: Optional[UsageInfo] = 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[Any], # Covers list[TodoWriteItem], list[ContentItem], etc. + dict[str, Any], # Covers structured results +] + + +class BaseTranscriptEntry(BaseModel): + parentUuid: Optional[str] + isSidechain: bool + userType: str + cwd: str + sessionId: str + version: str + uuid: str + timestamp: str + isMeta: Optional[bool] = None + agentId: Optional[str] = None # Agent ID for sidechain messages + gitBranch: Optional[str] = None # Git branch name when available + + +class UserTranscriptEntry(BaseTranscriptEntry): + type: Literal["user"] + message: UserMessageModel + toolUseResult: Optional[ToolUseResult] = None + agentId: Optional[str] = None # From toolUseResult when present + + +class AssistantTranscriptEntry(BaseTranscriptEntry): + type: Literal["assistant"] + message: AssistantMessageModel + requestId: Optional[str] = None + + +class SummaryTranscriptEntry(BaseModel): + type: Literal["summary"] + summary: str + leafUuid: str + cwd: Optional[str] = None + sessionId: None = None # Summaries don't have a sessionId + + +class SystemTranscriptEntry(BaseTranscriptEntry): + """System messages like warnings, notifications, hook summaries, etc.""" + + type: Literal["system"] + 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): + """Queue operations (enqueue/dequeue/remove) for message queueing tracking. + + enqueue/dequeue are internal operations that track when messages are queued and dequeued. + They are parsed but not rendered, as the content duplicates actual user messages. + + 'remove' operations are out-of-band user inputs made visible to the agent while working + for "steering" purposes. These should be rendered as user messages with a 'steering' CSS class. + Content can be a list of ContentItems or a simple string (for 'remove' operations). + """ + + type: Literal["queue-operation"] + operation: Literal["enqueue", "dequeue", "remove", "popAll"] + timestamp: str + sessionId: str + content: Optional[Union[list[ContentItem], str]] = ( + None # List for enqueue, str for remove/popAll + ) + + +TranscriptEntry = Union[ + UserTranscriptEntry, + AssistantTranscriptEntry, + SummaryTranscriptEntry, + SystemTranscriptEntry, + QueueOperationTranscriptEntry, +] + + # ============================================================================= # Message Metadata # ============================================================================= @@ -371,39 +555,6 @@ class IdeNotificationContent: remaining_text: str # Text after notifications extracted -# ============================================================================= -# Content Item Models (Pydantic) -# ============================================================================= -# These are content items that appear within message content arrays. -# Defined here before the dataclass models that reference them. - - -class TextContent(BaseModel): - """Text content block within a message content array.""" - - type: Literal["text"] - text: str - - -class ImageSource(BaseModel): - """Base64-encoded image source data.""" - - type: Literal["base64"] - media_type: str - data: str - - -class ImageContent(BaseModel): - """Image content. - - This represents an image within a content array, not a standalone message. - Images are always part of UserTextMessage.items or AssistantTextMessage.items. - """ - - type: Literal["image"] - source: ImageSource - - @dataclass class UserTextMessage(MessageContent): """Content for user text with interleaved images and IDE notifications. @@ -780,7 +931,7 @@ class ExitPlanModeInput(BaseModel): TodoWriteInput, AskUserQuestionInput, ExitPlanModeInput, - "ToolUseContent", # Generic fallback when no specialized parser + ToolUseContent, # Generic fallback when no specialized parser ] @@ -912,163 +1063,5 @@ class GrepOutput: EditOutput, # Add more specialized output types as they're implemented: # WriteOutput, BashOutput, TaskOutput, GlobOutput, GrepOutput - "ToolResultContent", # Generic fallback for unparsed results -] - - -# ============================================================================= -# Transcript Content Models (Pydantic) -# ============================================================================= -# Low-level content types parsed from JSONL transcript entries. -# These are the raw content items that appear in message content arrays. - - -class UsageInfo(BaseModel): - """Token usage information for tracking API consumption.""" - - 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 - - -class ToolUseContent(BaseModel): - type: Literal["tool_use"] - id: str - name: str - input: dict[str, Any] - - -class ToolResultContent(BaseModel): - type: Literal["tool_result"] - tool_use_id: str - content: Union[str, list[dict[str, Any]]] - is_error: Optional[bool] = None - agentId: Optional[str] = None # Reference to agent file for sub-agent messages - - -class ThinkingContent(BaseModel): - type: Literal["thinking"] - thinking: str - signature: Optional[str] = None - - -# Content item types that appear in message content arrays -ContentItem = Union[ - TextContent, - ToolUseContent, - ToolResultContent, - ThinkingContent, - ImageContent, -] - - -class UserMessageModel(BaseModel): - role: Literal["user"] - content: list[ContentItem] - usage: Optional["UsageInfo"] = ( - None # For type compatibility with AssistantMessageModel - ) - - -class AssistantMessageModel(BaseModel): - """Assistant message model.""" - - id: str - type: Literal["message"] - role: Literal["assistant"] - model: str - content: list[ContentItem] - stop_reason: Optional[str] = None - stop_sequence: Optional[str] = None - usage: Optional[UsageInfo] = 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[Any], # Covers list[TodoWriteItem], list[ContentItem], etc. - dict[str, Any], # Covers structured results -] - - -class BaseTranscriptEntry(BaseModel): - parentUuid: Optional[str] - isSidechain: bool - userType: str - cwd: str - sessionId: str - version: str - uuid: str - timestamp: str - isMeta: Optional[bool] = None - agentId: Optional[str] = None # Agent ID for sidechain messages - gitBranch: Optional[str] = None # Git branch name when available - - -class UserTranscriptEntry(BaseTranscriptEntry): - type: Literal["user"] - message: UserMessageModel - toolUseResult: Optional[ToolUseResult] = None - agentId: Optional[str] = None # From toolUseResult when present - - -class AssistantTranscriptEntry(BaseTranscriptEntry): - type: Literal["assistant"] - message: AssistantMessageModel - requestId: Optional[str] = None - - -class SummaryTranscriptEntry(BaseModel): - type: Literal["summary"] - summary: str - leafUuid: str - cwd: Optional[str] = None - sessionId: None = None # Summaries don't have a sessionId - - -class SystemTranscriptEntry(BaseTranscriptEntry): - """System messages like warnings, notifications, hook summaries, etc.""" - - type: Literal["system"] - 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): - """Queue operations (enqueue/dequeue/remove) for message queueing tracking. - - enqueue/dequeue are internal operations that track when messages are queued and dequeued. - They are parsed but not rendered, as the content duplicates actual user messages. - - 'remove' operations are out-of-band user inputs made visible to the agent while working - for "steering" purposes. These should be rendered as user messages with a 'steering' CSS class. - Content can be a list of ContentItems or a simple string (for 'remove' operations). - """ - - type: Literal["queue-operation"] - operation: Literal["enqueue", "dequeue", "remove", "popAll"] - timestamp: str - sessionId: str - content: Optional[Union[list[ContentItem], str]] = ( - None # List for enqueue, str for remove/popAll - ) - - -TranscriptEntry = Union[ - UserTranscriptEntry, - AssistantTranscriptEntry, - SummaryTranscriptEntry, - SystemTranscriptEntry, - QueueOperationTranscriptEntry, + ToolResultContent, # Generic fallback for unparsed results ] From 42ce9518444a917598310d3a529e4fee764f60e6 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 17:33:30 +0100 Subject: [PATCH 37/57] Refactor tool result parsing: move to factory with registry pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TOOL_OUTPUT_PARSERS registry in tool_factory.py (symmetric with TOOL_INPUT_MODELS) - Move parse_read_output and parse_edit_output from tool_formatters to tool_factory - Add create_tool_output() to parse raw ToolResultContent into typed outputs - Update renderer.py to dispatch based on pre-parsed output type (ReadOutput, EditOutput) - Remove redundant parsing from format_tool_result_content (now a pure fallback formatter) - Use walrus operators for cleaner registry lookup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 216 +++++++++++++++++++++- claude_code_log/html/__init__.py | 6 +- claude_code_log/html/renderer.py | 29 +-- claude_code_log/html/tool_formatters.py | 165 ++--------------- 4 files changed, 243 insertions(+), 173 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 8b6e5384..56b585ad 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -11,10 +11,12 @@ """ from dataclasses import dataclass -from typing import Any, Optional, cast +from typing import Any, Callable, Optional, cast from pydantic import BaseModel +import re + from ..models import ( # Tool input models AskUserQuestionInput, @@ -39,6 +41,10 @@ ToolUseContent, ToolUseMessage, WriteInput, + # Tool output models + EditOutput, + ReadOutput, + ToolOutput, ) from ..html import escape_html, format_tool_use_title @@ -240,6 +246,203 @@ def create_tool_input( return None +# ============================================================================= +# Tool Output Parsing +# ============================================================================= +# Parse raw tool result content into typed output models (ReadOutput, EditOutput, etc.) +# Symmetric with Tool Input parsing above. + + +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: Optional[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 (required for ReadOutput) + + Returns: + ReadOutput if parsing succeeds, None otherwise + """ + if not file_path: + return None + + # 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 parse_edit_output(content: str, file_path: Optional[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 (required for EditOutput) + + Returns: + EditOutput if parsing succeeds, None otherwise + """ + if not file_path: + return None + + # 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, + ) + + +# Registry of tool output parsers: tool_name -> parser(content, file_path) -> Optional[ToolOutput] +# Add more parsers as specialized output types are implemented. +TOOL_OUTPUT_PARSERS: dict[str, Callable[[str, Optional[str]], Optional[ToolOutput]]] = { + "Read": parse_read_output, + "Edit": parse_edit_output, + # TODO: Add more specialized output parsers: + # "Write": parse_write_output, + # "Bash": parse_bash_output, + # "Task": parse_task_output, + # "Glob": parse_glob_output, + # "Grep": parse_grep_output, +} + + +def create_tool_output( + tool_name: str, + tool_result: ToolResultContent, + file_path: Optional[str] = None, +) -> ToolOutput: + """Create typed tool output from raw ToolResultContent. + + Parses the raw content into specialized output types when possible, + using the TOOL_OUTPUT_PARSERS registry. + + Args: + tool_name: The name of the tool (e.g., "Bash", "Read") + tool_result: The raw tool result content + file_path: Optional file path for file-based tools (Read, Edit, Write) + + Returns: + A typed output model if parsing succeeds, ToolResultContent as fallback. + """ + # Handle both string and structured content + if not isinstance(tool_result.content, str): + # Structured content (list of dicts) - use generic fallback + return tool_result + + raw_content = tool_result.content + + # Look up parser in registry and parse if available + if (parser := TOOL_OUTPUT_PARSERS.get(tool_name)) and ( + parsed := parser(raw_content, file_path) + ): + return parsed + + # Fallback to raw ToolResultContent + return tool_result + + # ============================================================================= # Tool Item Processing # ============================================================================= @@ -331,13 +534,18 @@ def create_tool_result_message( ): result_file_path = tool_use_from_ctx.input["file_path"] + # Parse into typed output (ReadOutput, EditOutput, etc.) when possible + parsed_output = create_tool_output( + result_tool_name or "", + tool_result, + result_file_path, + ) + # Create content model with rendering context - # Pass the whole ToolResultContent as output (generic fallback) - # TODO: Parse into specialized output types (ReadOutput, EditOutput) when appropriate content_model = ToolResultMessage( meta, tool_use_id=tool_result.tool_use_id, - output=tool_result, # ToolResultContent as ToolOutput + output=parsed_output, is_error=tool_result.is_error or False, tool_name=result_tool_name, file_path=result_file_path, diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py index 615ec10b..73cf4c2c 100644 --- a/claude_code_log/html/__init__.py +++ b/claude_code_log/html/__init__.py @@ -33,8 +33,6 @@ format_tool_use_title, format_write_tool_content, get_tool_summary, - parse_edit_output, - parse_read_output, render_params_table, ) from .system_formatters import ( @@ -112,10 +110,8 @@ "format_write_tool_content", "get_tool_summary", "render_params_table", - # tool_formatters (output/result) - "parse_read_output", + # tool_formatters (output/result) - parse functions now in factories/tool_factory.py "format_read_tool_result", - "parse_edit_output", "format_edit_tool_result", "format_tool_result_content", # system_formatters diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index c99818e4..88bf6bf6 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -12,12 +12,13 @@ CommandOutputMessage, CompactedSummaryMessage, DedupNoticeMessage, + EditOutput, HookSummaryMessage, + ReadOutput, SessionHeaderMessage, SlashCommandMessage, SystemMessage, ThinkingMessage, - ToolResultContent, ToolResultMessage, ToolUseMessage, TranscriptEntry, @@ -138,16 +139,22 @@ def _build_dispatcher(self) -> dict[type, Callable[..., str]]: def _format_tool_result_content(self, content: ToolResultMessage) -> str: """Format ToolResultMessage with associated tool context.""" - # output is ToolOutput (either specialized output or ToolResultContent) - if isinstance(content.output, ToolResultContent): - return format_tool_result_content( - content.output, - content.file_path, - content.tool_name, - ) - # TODO: Handle specialized output types (ReadOutput, EditOutput) - # For now, fallback to string representation - return f"
{content.output}
" + # output is ToolOutput (either specialized or ToolResultContent fallback) + from .tool_formatters import ( + format_read_tool_result, + format_edit_tool_result, + ) + + if isinstance(content.output, ReadOutput): + return format_read_tool_result(content.output) + if isinstance(content.output, EditOutput): + return format_edit_tool_result(content.output) + # ToolResultContent fallback (the only remaining type in ToolOutput union) + return format_tool_result_content( + content.output, + content.file_path, + content.tool_name, + ) def _flatten_preorder( self, roots: list[TemplateMessage] diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 76dc9493..13b9dc98 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -266,99 +266,8 @@ def format_read_tool_content(read_input: ReadInput) -> str: # noqa: ARG001 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, - ) +# -- Tool Result Formatting --------------------------------------------------- +# Parsing (parse_read_output, parse_edit_output) is now in factories/tool_factory.py def format_read_tool_result(output: ReadOutput) -> str: @@ -387,49 +296,6 @@ def format_read_tool_result(output: ReadOutput) -> str: ) -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. @@ -792,15 +658,19 @@ def _looks_like_bash_output(content: str) -> bool: def format_tool_result_content( tool_result: ToolResultContent, - file_path: Optional[str] = None, + _file_path: Optional[str] = None, tool_name: Optional[str] = None, ) -> str: """Format tool result content as HTML, including images. + This is the fallback formatter for tool results that don't have specialized + output types (ReadOutput, EditOutput). Those are dispatched directly to + format_read_tool_result/format_edit_tool_result from renderer.py. + 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") + _file_path: Unused (kept for API compatibility) + tool_name: Optional tool name for specialized rendering (e.g., "Write", "Task") """ # Handle both string and structured content if isinstance(tool_result.content, str): @@ -868,17 +738,8 @@ def format_tool_result_content( 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) + # Note: Read and Edit tool results are now parsed upstream in tool_factory.py + # and dispatched to format_read_tool_result/format_edit_tool_result via renderer.py # 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 @@ -963,10 +824,8 @@ def format_tool_result_content( # File tools (input) "format_read_tool_content", "format_write_tool_content", - # File tools (output/result) - "parse_read_output", + # File tools (output/result) - parsing now in factories/tool_factory.py "format_read_tool_result", - "parse_edit_output", "format_edit_tool_result", # Edit tools "format_edit_tool_content", From 58836e5ed0cba26652c1e79ffa08b7b133a64a96 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 17:49:22 +0100 Subject: [PATCH 38/57] Unify tool content formatting: make format_tool_result_content a dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename format_tool_result_content(ToolResultContent) to _format_raw_tool_result (private) - Add format_tool_result_content(ToolResultMessage) as dispatcher (like format_tool_use_content) - Remove _format_tool_result_content method from HtmlRenderer - Both ToolUseMessage and ToolResultMessage now use same dispatcher pattern in renderer - Update tests to use _format_raw_tool_result for raw formatting tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 23 +------------- claude_code_log/html/tool_formatters.py | 39 +++++++++++++++++++----- test/test_bash_rendering.py | 8 ++--- test/test_tool_result_image_rendering.py | 12 ++++---- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 88bf6bf6..d61629d5 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -12,9 +12,7 @@ CommandOutputMessage, CompactedSummaryMessage, DedupNoticeMessage, - EditOutput, HookSummaryMessage, - ReadOutput, SessionHeaderMessage, SlashCommandMessage, SystemMessage, @@ -134,28 +132,9 @@ def _build_dispatcher(self) -> dict[type, Callable[..., str]]: UnknownMessage: format_unknown_content, # Tool content types ToolUseMessage: format_tool_use_content, - ToolResultMessage: self._format_tool_result_content, + ToolResultMessage: format_tool_result_content, } - def _format_tool_result_content(self, content: ToolResultMessage) -> str: - """Format ToolResultMessage with associated tool context.""" - # output is ToolOutput (either specialized or ToolResultContent fallback) - from .tool_formatters import ( - format_read_tool_result, - format_edit_tool_result, - ) - - if isinstance(content.output, ReadOutput): - return format_read_tool_result(content.output) - if isinstance(content.output, EditOutput): - return format_edit_tool_result(content.output) - # ToolResultContent fallback (the only remaining type in ToolOutput union) - return format_tool_result_content( - content.output, - content.file_path, - content.tool_name, - ) - def _flatten_preorder( self, roots: list[TemplateMessage] ) -> Tuple[ diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 13b9dc98..9591cdcc 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -39,6 +39,7 @@ TodoWriteInput, ToolInput, ToolResultContent, + ToolResultMessage, ToolUseContent, ToolUseMessage, WriteInput, @@ -656,20 +657,17 @@ def _looks_like_bash_output(content: str) -> bool: return False -def format_tool_result_content( +def _format_raw_tool_result( tool_result: ToolResultContent, - _file_path: Optional[str] = None, tool_name: Optional[str] = None, ) -> str: - """Format tool result content as HTML, including images. + """Format raw ToolResultContent as HTML (fallback formatter). - This is the fallback formatter for tool results that don't have specialized - output types (ReadOutput, EditOutput). Those are dispatched directly to - format_read_tool_result/format_edit_tool_result from renderer.py. + This handles tool results that don't have specialized output types. + Called by format_tool_result_content for the ToolResultContent fallback case. Args: - tool_result: The tool result content - _file_path: Unused (kept for API compatibility) + tool_result: The raw tool result content tool_name: Optional tool name for specialized rendering (e.g., "Write", "Task") """ # Handle both string and structured content @@ -810,6 +808,31 @@ def format_tool_result_content( """ +def format_tool_result_content(content: ToolResultMessage) -> str: + """Format ToolResultMessage as HTML. + + Dispatches to specialized formatters based on the parsed output type. + Falls back to _format_raw_tool_result if output is unparsed ToolResultContent. + + Args: + content: ToolResultMessage with parsed output and metadata + + Returns: + HTML string for the tool result content + """ + output = content.output + + # Dispatch based on parsed output type + if isinstance(output, ReadOutput): + return format_read_tool_result(output) + + if isinstance(output, EditOutput): + return format_edit_tool_result(output) + + # Fallback: raw ToolResultContent + return _format_raw_tool_result(output, content.tool_name) + + # -- Public Exports ----------------------------------------------------------- __all__ = [ diff --git a/test/test_bash_rendering.py b/test/test_bash_rendering.py index 775f0726..c268acbe 100644 --- a/test/test_bash_rendering.py +++ b/test/test_bash_rendering.py @@ -338,7 +338,7 @@ def test_bash_ansi_color_rendering(): def test_bash_tool_result_ansi_processing(): """Test that Bash tool results have ANSI codes processed.""" from claude_code_log.html.tool_formatters import ( - format_tool_result_content, + _format_raw_tool_result, _looks_like_bash_output, ) from claude_code_log.models import ToolResultContent @@ -355,7 +355,7 @@ def test_bash_tool_result_ansi_processing(): type="tool_result", tool_use_id="bash_123", content=bash_content, is_error=False ) - html = format_tool_result_content(tool_result) + html = _format_raw_tool_result(tool_result) # Should contain colored output assert '✔ Build completed' in html @@ -367,7 +367,7 @@ def test_bash_tool_result_ansi_processing(): def test_bash_tool_result_cursor_stripping(): """Test that cursor movement codes are stripped from Bash tool results.""" - from claude_code_log.html import format_tool_result_content + from claude_code_log.html.tool_formatters import _format_raw_tool_result from claude_code_log.models import ToolResultContent # Content with cursor movement codes @@ -380,7 +380,7 @@ def test_bash_tool_result_cursor_stripping(): is_error=False, ) - html = format_tool_result_content(tool_result) + html = _format_raw_tool_result(tool_result) # Should have colors but no cursor codes assert '✔ Done!' in html diff --git a/test/test_tool_result_image_rendering.py b/test/test_tool_result_image_rendering.py index 961de78a..6f82f34a 100644 --- a/test/test_tool_result_image_rendering.py +++ b/test/test_tool_result_image_rendering.py @@ -1,6 +1,6 @@ """Test image rendering within tool results.""" -from claude_code_log.html import format_tool_result_content +from claude_code_log.html.tool_formatters import _format_raw_tool_result from claude_code_log.models import ToolResultContent @@ -27,7 +27,7 @@ def test_tool_result_with_image(): is_error=False, ) - html = format_tool_result_content(tool_result) + html = _format_raw_tool_result(tool_result) # Should be collapsible when images are present assert '
' in html @@ -66,7 +66,7 @@ def test_tool_result_with_only_image(): is_error=False, ) - html = format_tool_result_content(tool_result) + html = _format_raw_tool_result(tool_result) # Should be collapsible assert '
' in html @@ -106,7 +106,7 @@ def test_tool_result_with_multiple_images(): is_error=False, ) - html = format_tool_result_content(tool_result) + html = _format_raw_tool_result(tool_result) # Should contain both images assert html.count("' not in html @@ -145,7 +145,7 @@ def test_tool_result_structured_text_only(): is_error=False, ) - html = format_tool_result_content(tool_result) + html = _format_raw_tool_result(tool_result) # Should contain both text lines assert "First line" in html From 9ceeb6cc23968ff4e14f3bd94254c3a34397203b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 17:58:07 +0100 Subject: [PATCH 39/57] Add registry pattern for tool formatters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TOOL_USE_FORMATTERS registry mapping input types to formatters - Add TOOL_RESULT_FORMATTERS registry mapping output types to formatters - Simplify format_tool_use_content and format_tool_result_content to use registry lookups with walrus operators - Consistent with TOOL_OUTPUT_PARSERS pattern in tool_factory.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/tool_formatters.py | 65 +++++++++++-------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 9591cdcc..43626d77 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -18,7 +18,7 @@ import binascii import json import re -from typing import Any, Optional, cast +from typing import Any, Callable, Optional, cast from .utils import ( escape_html, @@ -570,6 +570,19 @@ def render_params_table(params: dict[str, Any]) -> str: # -- Tool Use Dispatcher ------------------------------------------------------ +# Registry mapping input types to their formatters +TOOL_USE_FORMATTERS: dict[type, Callable[[Any], str]] = { + TodoWriteInput: format_todowrite_content, + BashInput: format_bash_tool_content, + EditInput: format_edit_tool_content, + MultiEditInput: format_multiedit_tool_content, + WriteInput: format_write_tool_content, + TaskInput: format_task_tool_content, + ReadInput: format_read_tool_content, + AskUserQuestionInput: format_askuserquestion_content, + ExitPlanModeInput: format_exitplanmode_content, +} + def format_tool_use_content(content: ToolUseMessage) -> str: """Format ToolUseMessage as HTML. @@ -585,33 +598,9 @@ def format_tool_use_content(content: ToolUseMessage) -> str: """ parsed_input = content.input - # Dispatch based on parsed type - if isinstance(parsed_input, TodoWriteInput): - return format_todowrite_content(parsed_input) - - if isinstance(parsed_input, BashInput): - return format_bash_tool_content(parsed_input) - - if isinstance(parsed_input, EditInput): - return format_edit_tool_content(parsed_input) - - if isinstance(parsed_input, MultiEditInput): - return format_multiedit_tool_content(parsed_input) - - if isinstance(parsed_input, WriteInput): - return format_write_tool_content(parsed_input) - - if isinstance(parsed_input, TaskInput): - return format_task_tool_content(parsed_input) - - if isinstance(parsed_input, ReadInput): - return format_read_tool_content(parsed_input) - - if isinstance(parsed_input, AskUserQuestionInput): - return format_askuserquestion_content(parsed_input) - - if isinstance(parsed_input, ExitPlanModeInput): - return format_exitplanmode_content(parsed_input) + # Dispatch based on parsed type via registry + if formatter := TOOL_USE_FORMATTERS.get(type(parsed_input)): + return formatter(parsed_input) # Fallback: ToolUseContent - render its input dict as params table if isinstance(parsed_input, ToolUseContent): @@ -808,6 +797,13 @@ def _format_raw_tool_result( """ +# Registry mapping output types to their formatters +TOOL_RESULT_FORMATTERS: dict[type, Callable[[Any], str]] = { + ReadOutput: format_read_tool_result, + EditOutput: format_edit_tool_result, +} + + def format_tool_result_content(content: ToolResultMessage) -> str: """Format ToolResultMessage as HTML. @@ -822,15 +818,12 @@ def format_tool_result_content(content: ToolResultMessage) -> str: """ output = content.output - # Dispatch based on parsed output type - if isinstance(output, ReadOutput): - return format_read_tool_result(output) - - if isinstance(output, EditOutput): - return format_edit_tool_result(output) + # Dispatch based on parsed output type via registry + if formatter := TOOL_RESULT_FORMATTERS.get(type(output)): + return formatter(output) - # Fallback: raw ToolResultContent - return _format_raw_tool_result(output, content.tool_name) + # Fallback: raw ToolResultContent (cast is safe - registry handles other types) + return _format_raw_tool_result(cast(ToolResultContent, output), content.tool_name) # -- Public Exports ----------------------------------------------------------- From 11c7420c2b9896fa7794dae145e514da5acd4925 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 18:35:13 +0100 Subject: [PATCH 40/57] Add symmetric output models and formatters for remaining tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add output models: AskUserQuestionOutput, ExitPlanModeOutput - Update BashOutput (content + has_ansi), TaskOutput (result only) - Add WriteOutput with success flag and message - Add parse functions: parse_write_output, parse_bash_output, parse_task_output, parse_askuserquestion_output, parse_exitplanmode_output - Move _looks_like_bash_output from tool_formatters to tool_factory - Add format functions for all new output types - Expand TOOL_OUTPUT_PARSERS and TOOL_RESULT_FORMATTERS registries (7 each) - Simplify _format_raw_tool_result to only handle generic text/images - Update tests to use new BashOutput model directly - Update dev-docs/messages.md to reflect completed implementations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 201 +++++++++++++++++++++- claude_code_log/html/tool_formatters.py | 192 ++++++++++++--------- claude_code_log/models.py | 63 +++++-- dev-docs/messages.md | 10 +- test/test_bash_rendering.py | 31 ++-- 5 files changed, 370 insertions(+), 127 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 56b585ad..0449e2a2 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -42,9 +42,15 @@ ToolUseMessage, WriteInput, # Tool output models + AskUserQuestionAnswer, + AskUserQuestionOutput, + BashOutput, EditOutput, + ExitPlanModeOutput, ReadOutput, + TaskOutput, ToolOutput, + WriteOutput, ) from ..html import escape_html, format_tool_use_title @@ -394,17 +400,198 @@ def parse_edit_output(content: str, file_path: Optional[str]) -> Optional[EditOu ) +def parse_write_output(content: str, file_path: Optional[str]) -> Optional[WriteOutput]: + """Parse Write tool result into structured content. + + Write tool results contain an acknowledgment on the first line. + We extract just the first line for display. + + Args: + content: Raw tool result string + file_path: Path to the file that was written (required for WriteOutput) + + Returns: + WriteOutput if parsing succeeds, None otherwise + """ + if not file_path: + return None + + lines = content.split("\n") + if not lines: + return None + + first_line = lines[0] + return WriteOutput( + file_path=file_path, + success=True, # If we got content, write succeeded + message=first_line, + ) + + +def parse_task_output(content: str, file_path: Optional[str]) -> Optional[TaskOutput]: + """Parse Task tool result into structured content. + + Task tool results contain the agent's response as markdown. + + Args: + content: Raw tool result string (agent's response) + file_path: Unused for Task tool + + Returns: + TaskOutput with the agent's response + """ + del file_path # Unused + if not content: + return None + + return TaskOutput(result=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 + 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 parse_bash_output(content: str, file_path: Optional[str]) -> Optional[BashOutput]: + """Parse Bash tool result into structured content. + + Detects ANSI escape sequences for terminal formatting. + + Args: + content: Raw tool result string + file_path: Unused for Bash tool + + Returns: + BashOutput with content and ANSI flag + """ + del file_path # Unused + if not content: + return None + + has_ansi = _looks_like_bash_output(content) + return BashOutput(content=content, has_ansi=has_ansi) + + +def parse_askuserquestion_output( + content: str, file_path: Optional[str] +) -> Optional[AskUserQuestionOutput]: + """Parse AskUserQuestion tool result into structured content. + + Parses the result format: + 'User has answered your questions: "Q1"="A1", "Q2"="A2". You can now continue...' + + Args: + content: Raw tool result string + file_path: Unused for AskUserQuestion tool + + Returns: + AskUserQuestionOutput with Q&A pairs + """ + del file_path # Unused + if not content: + return None + + # Check if this is a successful answer + if not content.startswith("User has answered your question"): + return None + + # Extract the Q&A portion between the colon and the final sentence + match = re.match( + r"User has answered your questions?: (.+)\. You can now continue", + content, + re.DOTALL, + ) + if not match: + return None + + qa_portion = match.group(1) + + # Parse "Question"="Answer" pairs + qa_pattern = re.compile(r'"([^"]+)"="([^"]+)"') + pairs = qa_pattern.findall(qa_portion) + + if not pairs: + return None + + answers = [AskUserQuestionAnswer(question=q, answer=a) for q, a in pairs] + return AskUserQuestionOutput(answers=answers, raw_message=content) + + +def parse_exitplanmode_output( + content: str, file_path: Optional[str] +) -> Optional[ExitPlanModeOutput]: + """Parse ExitPlanMode tool result into structured content. + + Truncates redundant plan echo on success. + 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) + + Args: + content: Raw tool result string + file_path: Unused for ExitPlanMode tool + + Returns: + ExitPlanModeOutput with truncated message + """ + del file_path # Unused + if not content: + return None + + approved = "User has approved your plan" in content + + if approved: + # Truncate at "## Approved Plan:" + marker = "## Approved Plan:" + marker_pos = content.find(marker) + if marker_pos > 0: + message = content[:marker_pos].rstrip() + else: + message = content + else: + message = content + + return ExitPlanModeOutput(message=message, approved=approved) + + # Registry of tool output parsers: tool_name -> parser(content, file_path) -> Optional[ToolOutput] -# Add more parsers as specialized output types are implemented. TOOL_OUTPUT_PARSERS: dict[str, Callable[[str, Optional[str]], Optional[ToolOutput]]] = { "Read": parse_read_output, "Edit": parse_edit_output, - # TODO: Add more specialized output parsers: - # "Write": parse_write_output, - # "Bash": parse_bash_output, - # "Task": parse_task_output, - # "Glob": parse_glob_output, - # "Grep": parse_grep_output, + "Write": parse_write_output, + "Bash": parse_bash_output, + "Task": parse_task_output, + "AskUserQuestion": parse_askuserquestion_output, + "ExitPlanMode": parse_exitplanmode_output, } diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 43626d77..16162187 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -28,14 +28,18 @@ from ..models import ( AskUserQuestionInput, AskUserQuestionItem, + AskUserQuestionOutput, BashInput, + BashOutput, EditInput, EditOutput, ExitPlanModeInput, + ExitPlanModeOutput, MultiEditInput, ReadInput, ReadOutput, TaskInput, + TaskOutput, TodoWriteInput, ToolInput, ToolResultContent, @@ -43,6 +47,7 @@ ToolUseContent, ToolUseMessage, WriteInput, + WriteOutput, ) from .ansi_colors import convert_ansi_to_html from .renderer_code import render_single_diff @@ -314,6 +319,102 @@ def format_edit_tool_result(output: EditOutput) -> str: ) +def format_write_tool_result(output: WriteOutput) -> str: + """Format Write tool result as HTML. + + Args: + output: Parsed WriteOutput with first line acknowledgment + + Returns: + HTML string with the acknowledgment message + """ + escaped_message = escape_html(output.message) + return f"
{escaped_message} ...
" + + +def format_bash_tool_result(output: BashOutput) -> str: + """Format Bash tool result as HTML with ANSI color support. + + Args: + output: Parsed BashOutput with content and ANSI flag + + Returns: + HTML string with ANSI colors converted or plain text + """ + content = output.content + if output.has_ansi: + full_html = convert_ansi_to_html(content) + else: + full_html = escape_html(content) + + # For short content, show directly + if len(content) <= 200: + return f"
{full_html}
" + + # For longer content, use collapsible details + preview_html = escape_html(content[:200]) + "..." + return f""" +
+ +
{preview_html}
+
+
+
{full_html}
+
+
+ """ + + +def format_task_tool_result(output: TaskOutput) -> str: + """Format Task tool result as HTML with markdown rendering. + + Args: + output: Parsed TaskOutput with agent's response + + Returns: + HTML string with markdown rendered in collapsible section + """ + return render_markdown_collapsible(output.result, "task-result") + + +def format_askuserquestion_output(output: AskUserQuestionOutput) -> str: + """Format AskUserQuestion tool result with styled Q&A pairs. + + Args: + output: Parsed AskUserQuestionOutput with Q&A pairs + + Returns: + HTML string with styled question/answer blocks + """ + html_parts: list[str] = [ + '
' + ] + + for qa in output.answers: + escaped_q = escape_html(qa.question) + escaped_a = escape_html(qa.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_output(output: ExitPlanModeOutput) -> str: + """Format ExitPlanMode tool result as HTML. + + Args: + output: Parsed ExitPlanModeOutput with truncated message + + Returns: + HTML string with the (truncated) result message + """ + escaped_content = escape_html(output.message) + return f"
{escaped_content}
" + + def format_write_tool_content(write_input: WriteInput) -> str: """Format Write tool use content with Pygments syntax highlighting. @@ -613,51 +714,18 @@ def format_tool_use_content(content: ToolUseMessage) -> str: # -- 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_raw_tool_result( tool_result: ToolResultContent, - tool_name: Optional[str] = None, + tool_name: Optional[str] = None, # noqa: ARG001 ) -> str: """Format raw ToolResultContent as HTML (fallback formatter). - This handles tool results that don't have specialized output types. - Called by format_tool_result_content for the ToolResultContent fallback case. + This handles tool results that don't have specialized output types, + including structured content with embedded images. Args: tool_result: The raw tool result content - tool_name: Optional tool name for specialized rendering (e.g., "Write", "Task") + tool_name: Unused (kept for API compatibility) """ # Handle both string and structured content if isinstance(tool_result.content, str): @@ -713,46 +781,11 @@ def _format_raw_tool_result( raw_content, flags=re.DOTALL, ) - # Remove "String: ..." portions that echo the input (everything after "String:" to end) + # Remove "String: ..." portions that echo the input 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} ...
" - - # Note: Read and Edit tool results are now parsed upstream in tool_factory.py - # and dispatched to format_read_tool_result/format_edit_tool_result via renderer.py - - # 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 - is_ansi = _looks_like_bash_output(raw_content) - full_html = ( - convert_ansi_to_html(raw_content) if is_ansi else escape_html(raw_content) - ) - # For preview, always use plain escaped text (don't truncate HTML with tags) + # Format the content + full_html = escape_html(raw_content) preview_html = ( escape_html(raw_content[:200]) + "..." if len(raw_content) > 200 @@ -779,12 +812,12 @@ def _format_raw_tool_result(
""" else: - # Text-only content (existing behavior) + # Text-only content # For simple content, show directly without collapsible wrapper if len(raw_content) <= 200: return f"
{full_html}
" - # For longer content, use collapsible details but no extra wrapper + # For longer content, use collapsible details return f"""
@@ -801,6 +834,11 @@ def _format_raw_tool_result( TOOL_RESULT_FORMATTERS: dict[type, Callable[[Any], str]] = { ReadOutput: format_read_tool_result, EditOutput: format_edit_tool_result, + WriteOutput: format_write_tool_result, + BashOutput: format_bash_tool_result, + TaskOutput: format_task_tool_result, + AskUserQuestionOutput: format_askuserquestion_output, + ExitPlanModeOutput: format_exitplanmode_output, } diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 97d2a2ac..fb72278c 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -965,13 +965,11 @@ class WriteOutput: """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 + message: str # First line acknowledgment (truncated from full output) @dataclass @@ -1002,15 +1000,11 @@ class BashOutput: """Parsed Bash tool output. Symmetric with BashInput for tool_use → tool_result pairing. - - TODO: Not currently used - tool results handled as raw strings. + Contains the output with ANSI flag for terminal formatting. """ - stdout: str - stderr: str - exit_code: Optional[int] - interrupted: bool - is_image: bool # True if output contains image data + content: str # Output content (stdout/stderr combined) + has_ansi: bool # True if content contains ANSI escape sequences @dataclass @@ -1018,13 +1012,10 @@ class TaskOutput: """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. + Contains the agent's final response as markdown. """ - agent_id: Optional[str] - result: str # Agent's response - is_background: bool + result: str # Agent's response (markdown) @dataclass @@ -1056,12 +1047,48 @@ class GrepOutput: truncated: bool +@dataclass +class AskUserQuestionAnswer: + """Single Q&A pair from AskUserQuestion result.""" + + question: str + answer: str + + +@dataclass +class AskUserQuestionOutput: + """Parsed AskUserQuestion tool output. + + Symmetric with AskUserQuestionInput for tool_use → tool_result pairing. + Contains the Q&A pairs extracted from the result message. + """ + + answers: list[AskUserQuestionAnswer] # Q&A pairs + raw_message: str # Original message for fallback + + +@dataclass +class ExitPlanModeOutput: + """Parsed ExitPlanMode tool output. + + Symmetric with ExitPlanModeInput for tool_use → tool_result pairing. + Truncates redundant plan echo on success. + """ + + message: str # Truncated message (without redundant plan) + approved: bool # Whether the plan was approved + + # Union of all specialized output types + ToolResultContent as generic fallback -# Note: Uses forward reference for ToolResultContent (defined later with ContentItem types) ToolOutput = Union[ ReadOutput, + WriteOutput, EditOutput, - # Add more specialized output types as they're implemented: - # WriteOutput, BashOutput, TaskOutput, GlobOutput, GrepOutput + BashOutput, + TaskOutput, + AskUserQuestionOutput, + ExitPlanModeOutput, + # TODO: Add as parsers are implemented: + # GlobOutput, GrepOutput ToolResultContent, # Generic fallback for unparsed results ] diff --git a/dev-docs/messages.md b/dev-docs/messages.md index 614934a9..e10a4f60 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -314,14 +314,16 @@ Tool results appear as `ToolResultContent` items in user messages, linked to the |------|--------------|------------|-------| | 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) | +| Write | `WriteOutput` | file_path, success, message | [tool_result](messages/tools/Write-tool_result.json) | +| Bash | `BashOutput` | content, has_ansi | [tool_result](messages/tools/Bash-tool_result.json) | +| Task | `TaskOutput` | result | [tool_result](messages/tools/Task-tool_result.json) | +| AskUserQuestion | `AskUserQuestionOutput` | answers, raw_message | [tool_result](messages/tools/AskUserQuestion-tool_result.json) | +| ExitPlanMode | `ExitPlanModeOutput` | message, approved | [tool_result](messages/tools/ExitPlanMode-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. +**(TODO)**: Glob and Grep output models defined in models.py but not yet used. ### Generic Tool Result diff --git a/test/test_bash_rendering.py b/test/test_bash_rendering.py index c268acbe..9f2f07eb 100644 --- a/test/test_bash_rendering.py +++ b/test/test_bash_rendering.py @@ -337,11 +337,9 @@ def test_bash_ansi_color_rendering(): def test_bash_tool_result_ansi_processing(): """Test that Bash tool results have ANSI codes processed.""" - from claude_code_log.html.tool_formatters import ( - _format_raw_tool_result, - _looks_like_bash_output, - ) - from claude_code_log.models import ToolResultContent + from claude_code_log.factories.tool_factory import _looks_like_bash_output + from claude_code_log.html.tool_formatters import format_bash_tool_result + from claude_code_log.models import BashOutput # Test the detection function bash_content = "❯ npm run build\n\x1b[32m✔ Build completed\x1b[0m" @@ -350,12 +348,9 @@ def test_bash_tool_result_ansi_processing(): regular_content = "This is just regular text output" assert not _looks_like_bash_output(regular_content) - # Test tool result processing with ANSI codes - tool_result = ToolResultContent( - type="tool_result", tool_use_id="bash_123", content=bash_content, is_error=False - ) - - html = _format_raw_tool_result(tool_result) + # Test BashOutput formatting with ANSI codes + bash_output = BashOutput(content=bash_content, has_ansi=True) + html = format_bash_tool_result(bash_output) # Should contain colored output assert '✔ Build completed' in html @@ -367,20 +362,14 @@ def test_bash_tool_result_ansi_processing(): def test_bash_tool_result_cursor_stripping(): """Test that cursor movement codes are stripped from Bash tool results.""" - from claude_code_log.html.tool_formatters import _format_raw_tool_result - from claude_code_log.models import ToolResultContent + from claude_code_log.html.tool_formatters import format_bash_tool_result + from claude_code_log.models import BashOutput # Content with cursor movement codes content_with_cursor = "Building...\x1b[1A\x1b[2K\x1b[32m✔ Done!\x1b[0m" - tool_result = ToolResultContent( - type="tool_result", - tool_use_id="bash_456", - content=content_with_cursor, - is_error=False, - ) - - html = _format_raw_tool_result(tool_result) + bash_output = BashOutput(content=content_with_cursor, has_ansi=True) + html = format_bash_tool_result(bash_output) # Should have colors but no cursor codes assert '✔ Done!' in html From 4138dda5381d45987b2118c408fe4d5a84500037 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 20:28:43 +0100 Subject: [PATCH 41/57] Refactor Renderer to use method-based dispatcher pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace _build_dispatcher() dict with format_{ClassName} methods - format_content() uses getattr() to find methods by MRO - Base Renderer defines 15 formatter methods with fallback chains - HtmlRenderer overrides methods to call format_x_content functions - Each method documents its fallback (e.g., format_HookSummaryMessage falls back to format_SystemMessage) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 97 ++++++++++----- claude_code_log/renderer.py | 196 ++++++++++++++++++++++++++----- 2 files changed, 234 insertions(+), 59 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index d61629d5..be0a038d 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -1,8 +1,7 @@ """HTML renderer implementation for Claude Code transcripts.""" -from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple +from typing import TYPE_CHECKING, Any, Optional, Tuple from ..cache import get_library_version from ..models import ( @@ -104,36 +103,72 @@ def check_html_version(html_file_path: Path) -> Optional[str]: class HtmlRenderer(Renderer): """HTML renderer for Claude Code transcripts.""" - def _build_dispatcher(self) -> dict[type, Callable[..., str]]: - """Build content type to HTML formatter mapping. + # ------------------------------------------------------------------------- + # System Content Formatters + # ------------------------------------------------------------------------- - 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(). - """ - return { - # System content types - SystemMessage: format_system_content, - HookSummaryMessage: format_hook_summary_content, - SessionHeaderMessage: format_session_header_content, - DedupNoticeMessage: format_dedup_notice_content, - # User content types - SlashCommandMessage: format_slash_command_content, - CommandOutputMessage: format_command_output_content, - BashInputMessage: format_bash_input_content, - BashOutputMessage: format_bash_output_content, - CompactedSummaryMessage: format_compacted_summary_content, - UserMemoryMessage: format_user_memory_content, - UserSlashCommandMessage: format_user_slash_command_content, - UserTextMessage: format_user_text_model_content, - # Assistant content types - ThinkingMessage: partial(format_thinking_content, line_threshold=10), - AssistantTextMessage: format_assistant_text_content, - UnknownMessage: format_unknown_content, - # Tool content types - ToolUseMessage: format_tool_use_content, - ToolResultMessage: format_tool_result_content, - } + def format_SystemMessage(self, message: SystemMessage) -> str: + return format_system_content(message) + + def format_HookSummaryMessage(self, message: HookSummaryMessage) -> str: + return format_hook_summary_content(message) + + def format_SessionHeaderMessage(self, message: SessionHeaderMessage) -> str: + return format_session_header_content(message) + + def format_DedupNoticeMessage(self, message: DedupNoticeMessage) -> str: + return format_dedup_notice_content(message) + + # ------------------------------------------------------------------------- + # User Content Formatters + # ------------------------------------------------------------------------- + + def format_UserTextMessage(self, message: UserTextMessage) -> str: + return format_user_text_model_content(message) + + def format_UserSlashCommandMessage(self, message: UserSlashCommandMessage) -> str: + return format_user_slash_command_content(message) + + def format_SlashCommandMessage(self, message: SlashCommandMessage) -> str: + return format_slash_command_content(message) + + def format_CommandOutputMessage(self, message: CommandOutputMessage) -> str: + return format_command_output_content(message) + + def format_BashInputMessage(self, message: BashInputMessage) -> str: + return format_bash_input_content(message) + + def format_BashOutputMessage(self, message: BashOutputMessage) -> str: + return format_bash_output_content(message) + + def format_CompactedSummaryMessage(self, message: CompactedSummaryMessage) -> str: + return format_compacted_summary_content(message) + + def format_UserMemoryMessage(self, message: UserMemoryMessage) -> str: + return format_user_memory_content(message) + + # ------------------------------------------------------------------------- + # Assistant Content Formatters + # ------------------------------------------------------------------------- + + def format_AssistantTextMessage(self, message: AssistantTextMessage) -> str: + return format_assistant_text_content(message) + + def format_ThinkingMessage(self, message: ThinkingMessage) -> str: + return format_thinking_content(message, line_threshold=10) + + def format_UnknownMessage(self, message: UnknownMessage) -> str: + return format_unknown_content(message) + + # ------------------------------------------------------------------------- + # Tool Content Formatters + # ------------------------------------------------------------------------- + + def format_ToolUseMessage(self, message: ToolUseMessage) -> str: + return format_tool_use_content(message) + + def format_ToolResultMessage(self, message: ToolResultMessage) -> str: + return format_tool_result_content(message) def _flatten_preorder( self, roots: list[TemplateMessage] diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 0da390c3..121dadf7 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -5,11 +5,20 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Optional, Tuple, TYPE_CHECKING, cast +from typing import Any, Optional, Tuple, TYPE_CHECKING, cast if TYPE_CHECKING: from .cache import CacheManager - from .models import MessageContent + from .models import ( + MessageContent, + # For formatter method type hints + BashInputMessage, + BashOutputMessage, + CompactedSummaryMessage, + HookSummaryMessage, + ThinkingMessage, + UserMemoryMessage, + ) from datetime import datetime from .models import ( @@ -2028,34 +2037,19 @@ class Renderer: Subclasses implement format-specific rendering (HTML, Markdown, etc.). - The dispatcher pattern enables automatic content formatting based on type: - - Subclasses override _build_dispatcher() to map content types to formatters - - format_content() walks the MRO to find the most specific handler - - Fallback to parent class handlers if no specific handler exists + The method-based dispatcher pattern: + - Base class defines format_xyz_message() methods for each content type + - Each method documents its fallback chain (which method it delegates to) + - format_content() walks the MRO to find the most specific method + - Subclasses override methods to implement format-specific rendering """ - def __init__(self): - self._dispatcher = self._build_dispatcher() - - def _build_dispatcher( - self, - ) -> dict[type, Callable[..., str]]: - """Build the content type to formatter mapping. - - Override in subclasses to register format-specific handlers. - The dict maps MessageContent subclasses to formatter functions. - Each formatter receives the content directly (cast to the matched type). - - Returns: - Dict mapping content types to formatter functions. - """ - return {} - def format_content(self, message: "TemplateMessage") -> str: - """Format message content by dispatching to type-specific handler. + """Format message content by dispatching to type-specific method. - Walks the content type's MRO to find the most specific registered - handler. This allows handlers for parent classes to serve as fallbacks. + Looks for a method named format_{ClassName} (e.g., format_SystemMessage). + Walks the content type's MRO to find the most specific format method. + This allows methods for parent classes to serve as fallbacks. Args: message: TemplateMessage with content to format. @@ -2066,10 +2060,156 @@ def format_content(self, message: "TemplateMessage") -> str: for cls in type(message.content).__mro__: if cls is object: break - if fmt := self._dispatcher.get(cls): - return fmt(message.content) + if method := getattr(self, f"format_{cls.__name__}", None): + return method(message.content) + return "" + + # ------------------------------------------------------------------------- + # System Content Formatters + # ------------------------------------------------------------------------- + + def format_SystemMessage(self, message: "SystemMessage") -> str: + """Format SystemMessage content. + + Fallback: None (base handler for system messages). + """ + return "" + + def format_HookSummaryMessage(self, message: "HookSummaryMessage") -> str: + """Format HookSummaryMessage content (hook execution results). + + Fallback: format_SystemMessage (HookSummaryMessage is system-related). + """ + return self.format_SystemMessage(message) # type: ignore[arg-type] + + def format_SessionHeaderMessage(self, message: "SessionHeaderMessage") -> str: + """Format SessionHeaderMessage content (session start markers). + + Fallback: None (standalone content type). + """ + return "" + + def format_DedupNoticeMessage(self, message: "DedupNoticeMessage") -> str: + """Format DedupNoticeMessage content (duplicate content notices). + + Fallback: None (standalone content type). + """ + return "" + + # ------------------------------------------------------------------------- + # User Content Formatters + # ------------------------------------------------------------------------- + + def format_UserTextMessage(self, message: "UserTextMessage") -> str: + """Format UserTextMessage content (user input with text/images). + + Fallback: None (base handler for user text messages). + """ + return "" + + def format_UserSteeringMessage(self, message: "UserSteeringMessage") -> str: + """Format UserSteeringMessage content (out-of-band steering input). + + Fallback: format_UserTextMessage (UserSteeringMessage extends UserTextMessage). + """ + return self.format_UserTextMessage(message) + + def format_UserSlashCommandMessage(self, message: "UserSlashCommandMessage") -> str: + """Format UserSlashCommandMessage content (user slash commands). + + Fallback: format_UserTextMessage (similar content structure). + """ + return self.format_UserTextMessage(message) # type: ignore[arg-type] + + def format_SlashCommandMessage(self, message: "SlashCommandMessage") -> str: + """Format SlashCommandMessage content (system slash commands). + + Fallback: None (standalone content type). + """ + return "" + + def format_CommandOutputMessage(self, message: "CommandOutputMessage") -> str: + """Format CommandOutputMessage content (slash command output). + + Fallback: None (standalone content type). + """ + return "" + + def format_BashInputMessage(self, message: "BashInputMessage") -> str: + """Format BashInputMessage content (bash command input). + + Fallback: None (standalone content type). + """ return "" + def format_BashOutputMessage(self, message: "BashOutputMessage") -> str: + """Format BashOutputMessage content (bash command output). + + Fallback: None (standalone content type). + """ + return "" + + def format_CompactedSummaryMessage(self, message: "CompactedSummaryMessage") -> str: + """Format CompactedSummaryMessage content (context summaries). + + Fallback: None (standalone content type). + """ + return "" + + def format_UserMemoryMessage(self, message: "UserMemoryMessage") -> str: + """Format UserMemoryMessage content (memory/context updates). + + Fallback: None (standalone content type). + """ + return "" + + # ------------------------------------------------------------------------- + # Assistant Content Formatters + # ------------------------------------------------------------------------- + + def format_AssistantTextMessage(self, message: "AssistantTextMessage") -> str: + """Format AssistantTextMessage content (assistant responses). + + Fallback: None (base handler for assistant messages). + """ + return "" + + def format_ThinkingMessage(self, message: "ThinkingMessage") -> str: + """Format ThinkingMessage content (assistant reasoning). + + Fallback: None (standalone content type). + """ + return "" + + def format_UnknownMessage(self, message: "UnknownMessage") -> str: + """Format UnknownMessage content (unrecognized content types). + + Fallback: None (standalone content type). + """ + return "" + + # ------------------------------------------------------------------------- + # Tool Content Formatters + # ------------------------------------------------------------------------- + + def format_ToolUseMessage(self, message: "ToolUseMessage") -> str: + """Format ToolUseMessage content (tool invocations). + + Fallback: None (standalone content type). + """ + return "" + + def format_ToolResultMessage(self, message: "ToolResultMessage") -> str: + """Format ToolResultMessage content (tool results). + + Fallback: None (standalone content type). + """ + return "" + + # ------------------------------------------------------------------------- + # Rendering Entry Points + # ------------------------------------------------------------------------- + def generate( self, messages: list[TranscriptEntry], From 23610c5efcc81db4760f3fd944075af7c83e024c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 21:06:41 +0100 Subject: [PATCH 42/57] Extend method-based dispatcher pattern to tool inputs/outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add format_ToolUseMessage in Renderer to dispatch to format_{InputClass} - Add format_ToolResultMessage in Renderer to dispatch to format_{OutputClass} - Add stub methods for all tool input types (format_BashInput, etc.) - Add stub methods for all tool output types (format_ReadOutput, etc.) - Rename format_*_content to format_*_input for consistency - Rename format_*_tool_result to format_*_output for consistency - Remove TOOL_USE_FORMATTERS and TOOL_RESULT_FORMATTERS registries - Update HtmlRenderer to implement format_{Input/Output} methods - Update test files to use new function names This prepares the codebase for implementing alternative renderers by making the dispatch logic reusable across renderer implementations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/__init__.py | 72 +++++---- claude_code_log/html/renderer.py | 105 ++++++++++++- claude_code_log/html/tool_formatters.py | 161 +++++--------------- claude_code_log/renderer.py | 184 ++++++++++++++++++++++- test/test_askuserquestion_rendering.py | 16 +- test/test_bash_rendering.py | 8 +- test/test_exitplanmode_rendering.py | 8 +- test/test_todowrite_rendering.py | 18 ++- test/test_tool_result_image_rendering.py | 12 +- 9 files changed, 393 insertions(+), 191 deletions(-) diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py index 73cf4c2c..168ba9e2 100644 --- a/claude_code_log/html/__init__.py +++ b/claude_code_log/html/__init__.py @@ -16,22 +16,31 @@ starts_with_emoji, ) from .tool_formatters import ( - format_askuserquestion_content, + # Tool input formatters (called by HtmlRenderer.format_{InputClass}) + format_askuserquestion_input, + format_bash_input, + format_edit_input, + format_exitplanmode_input, + format_multiedit_input, + format_read_input, + format_task_input, + format_todowrite_input, + format_write_input, + # Tool output formatters (called by HtmlRenderer.format_{OutputClass}) + format_askuserquestion_output, + format_bash_output, + format_edit_output, + format_exitplanmode_output, + format_read_output, + format_task_output, + format_write_output, + # Fallback formatter + format_tool_result_content_raw, + # Legacy formatters (still used) format_askuserquestion_result, - format_bash_tool_content, - format_edit_tool_content, - format_edit_tool_result, - format_exitplanmode_content, format_exitplanmode_result, - format_multiedit_tool_content, - format_read_tool_content, - format_read_tool_result, - format_task_tool_content, - format_todowrite_content, - format_tool_result_content, - format_tool_use_content, + # Tool summary and title format_tool_use_title, - format_write_tool_content, get_tool_summary, render_params_table, ) @@ -94,26 +103,33 @@ "render_markdown", "render_markdown_collapsible", "starts_with_emoji", - # tool_formatters (input) - "format_askuserquestion_content", + # tool_formatters (input) - called by HtmlRenderer.format_{InputClass} + "format_askuserquestion_input", + "format_bash_input", + "format_edit_input", + "format_exitplanmode_input", + "format_multiedit_input", + "format_read_input", + "format_task_input", + "format_todowrite_input", + "format_write_input", + # tool_formatters (output) - called by HtmlRenderer.format_{OutputClass} + "format_askuserquestion_output", + "format_bash_output", + "format_edit_output", + "format_exitplanmode_output", + "format_read_output", + "format_task_output", + "format_write_output", + # Fallback formatter + "format_tool_result_content_raw", + # Legacy formatters (still used) "format_askuserquestion_result", - "format_bash_tool_content", - "format_edit_tool_content", - "format_exitplanmode_content", "format_exitplanmode_result", - "format_multiedit_tool_content", - "format_read_tool_content", - "format_task_tool_content", - "format_todowrite_content", - "format_tool_use_content", + # Tool summary and title "format_tool_use_title", - "format_write_tool_content", "get_tool_summary", "render_params_table", - # tool_formatters (output/result) - parse functions now in factories/tool_factory.py - "format_read_tool_result", - "format_edit_tool_result", - "format_tool_result_content", # system_formatters "format_dedup_notice_content", "format_hook_summary_content", diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index be0a038d..c603dde5 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -16,13 +16,31 @@ SlashCommandMessage, SystemMessage, ThinkingMessage, - ToolResultMessage, - ToolUseMessage, TranscriptEntry, UnknownMessage, UserMemoryMessage, UserSlashCommandMessage, UserTextMessage, + # Tool input types + AskUserQuestionInput, + BashInput, + EditInput, + ExitPlanModeInput, + MultiEditInput, + ReadInput, + TaskInput, + TodoWriteInput, + ToolUseContent, + WriteInput, + # Tool output types + AskUserQuestionOutput, + BashOutput, + EditOutput, + ExitPlanModeOutput, + ReadOutput, + TaskOutput, + ToolResultContent, + WriteOutput, ) from ..renderer import ( Renderer, @@ -59,7 +77,26 @@ format_thinking_content, format_unknown_content, ) -from .tool_formatters import format_tool_result_content, format_tool_use_content +from .tool_formatters import ( + format_askuserquestion_input, + format_askuserquestion_output, + format_bash_input, + format_bash_output, + format_edit_input, + format_edit_output, + format_exitplanmode_input, + format_exitplanmode_output, + format_multiedit_input, + format_read_input, + format_read_output, + format_task_input, + format_task_output, + format_todowrite_input, + format_tool_result_content_raw, + format_write_input, + format_write_output, + render_params_table, +) from .utils import ( css_class_from_message, get_message_emoji, @@ -161,14 +198,66 @@ def format_UnknownMessage(self, message: UnknownMessage) -> str: return format_unknown_content(message) # ------------------------------------------------------------------------- - # Tool Content Formatters + # Tool Input Formatters + # ------------------------------------------------------------------------- + + def format_BashInput(self, input: BashInput) -> str: + return format_bash_input(input) + + def format_ReadInput(self, input: ReadInput) -> str: + return format_read_input(input) + + def format_WriteInput(self, input: WriteInput) -> str: + return format_write_input(input) + + def format_EditInput(self, input: EditInput) -> str: + return format_edit_input(input) + + def format_MultiEditInput(self, input: MultiEditInput) -> str: + return format_multiedit_input(input) + + def format_TaskInput(self, input: TaskInput) -> str: + return format_task_input(input) + + def format_TodoWriteInput(self, input: TodoWriteInput) -> str: + return format_todowrite_input(input) + + def format_AskUserQuestionInput(self, input: AskUserQuestionInput) -> str: + return format_askuserquestion_input(input) + + def format_ExitPlanModeInput(self, input: ExitPlanModeInput) -> str: + return format_exitplanmode_input(input) + + def format_ToolUseContent(self, input: ToolUseContent) -> str: + return render_params_table(input.input) + + # ------------------------------------------------------------------------- + # Tool Output Formatters # ------------------------------------------------------------------------- - def format_ToolUseMessage(self, message: ToolUseMessage) -> str: - return format_tool_use_content(message) + def format_ReadOutput(self, output: ReadOutput) -> str: + return format_read_output(output) + + def format_WriteOutput(self, output: WriteOutput) -> str: + return format_write_output(output) + + def format_EditOutput(self, output: EditOutput) -> str: + return format_edit_output(output) + + def format_BashOutput(self, output: BashOutput) -> str: + return format_bash_output(output) + + def format_TaskOutput(self, output: TaskOutput) -> str: + return format_task_output(output) + + def format_AskUserQuestionOutput(self, output: AskUserQuestionOutput) -> str: + return format_askuserquestion_output(output) + + def format_ExitPlanModeOutput(self, output: ExitPlanModeOutput) -> str: + return format_exitplanmode_output(output) - def format_ToolResultMessage(self, message: ToolResultMessage) -> str: - return format_tool_result_content(message) + def format_ToolResultContent(self, output: ToolResultContent) -> str: + return format_tool_result_content_raw(output) def _flatten_preorder( self, roots: list[TemplateMessage] diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 16162187..9b0f6ac0 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -18,7 +18,7 @@ import binascii import json import re -from typing import Any, Callable, Optional, cast +from typing import Any, Optional, cast from .utils import ( escape_html, @@ -43,9 +43,6 @@ TodoWriteInput, ToolInput, ToolResultContent, - ToolResultMessage, - ToolUseContent, - ToolUseMessage, WriteInput, WriteOutput, ) @@ -89,7 +86,7 @@ def _render_question_item(q: AskUserQuestionItem) -> str: return "".join(html_parts) -def format_askuserquestion_content(ask_input: AskUserQuestionInput) -> str: +def format_askuserquestion_input(ask_input: AskUserQuestionInput) -> str: """Format AskUserQuestion tool use content with prominent question display. Args: @@ -169,7 +166,7 @@ def format_askuserquestion_result(content: str) -> str: # -- ExitPlanMode Tool -------------------------------------------------------- -def format_exitplanmode_content(exit_input: ExitPlanModeInput) -> str: +def format_exitplanmode_input(exit_input: ExitPlanModeInput) -> str: """Format ExitPlanMode tool use content with collapsible plan markdown. Args: @@ -210,7 +207,7 @@ def format_exitplanmode_result(content: str) -> str: # -- TodoWrite Tool ----------------------------------------------------------- -def format_todowrite_content(todo_input: TodoWriteInput) -> str: +def format_todowrite_input(todo_input: TodoWriteInput) -> str: """Format TodoWrite tool use content as a todo list. Args: @@ -259,7 +256,7 @@ def format_todowrite_content(todo_input: TodoWriteInput) -> str: # -- File Tools (Read/Write) -------------------------------------------------- -def format_read_tool_content(read_input: ReadInput) -> str: # noqa: ARG001 +def format_read_input(read_input: ReadInput) -> str: # noqa: ARG001 """Format Read tool use content showing file path. Args: @@ -276,7 +273,7 @@ def format_read_tool_content(read_input: ReadInput) -> str: # noqa: ARG001 # Parsing (parse_read_output, parse_edit_output) is now in factories/tool_factory.py -def format_read_tool_result(output: ReadOutput) -> str: +def format_read_output(output: ReadOutput) -> str: """Format Read tool result as HTML with syntax highlighting. Args: @@ -302,7 +299,7 @@ def format_read_tool_result(output: ReadOutput) -> str: ) -def format_edit_tool_result(output: EditOutput) -> str: +def format_edit_output(output: EditOutput) -> str: """Format Edit tool result as HTML with syntax highlighting. Args: @@ -319,7 +316,7 @@ def format_edit_tool_result(output: EditOutput) -> str: ) -def format_write_tool_result(output: WriteOutput) -> str: +def format_write_output(output: WriteOutput) -> str: """Format Write tool result as HTML. Args: @@ -332,7 +329,7 @@ def format_write_tool_result(output: WriteOutput) -> str: return f"
{escaped_message} ...
" -def format_bash_tool_result(output: BashOutput) -> str: +def format_bash_output(output: BashOutput) -> str: """Format Bash tool result as HTML with ANSI color support. Args: @@ -365,7 +362,7 @@ def format_bash_tool_result(output: BashOutput) -> str: """ -def format_task_tool_result(output: TaskOutput) -> str: +def format_task_output(output: TaskOutput) -> str: """Format Task tool result as HTML with markdown rendering. Args: @@ -415,7 +412,7 @@ def format_exitplanmode_output(output: ExitPlanModeOutput) -> str: return f"
{escaped_content}
" -def format_write_tool_content(write_input: WriteInput) -> str: +def format_write_input(write_input: WriteInput) -> str: """Format Write tool use content with Pygments syntax highlighting. Args: @@ -430,7 +427,7 @@ def format_write_tool_content(write_input: WriteInput) -> str: # -- Edit Tools (Edit/Multiedit) ---------------------------------------------- -def format_edit_tool_content(edit_input: EditInput) -> str: +def format_edit_input(edit_input: EditInput) -> str: """Format Edit tool use content as a diff view with intra-line highlighting. Args: @@ -451,7 +448,7 @@ def format_edit_tool_content(edit_input: EditInput) -> str: return "".join(html_parts) -def format_multiedit_tool_content(multiedit_input: MultiEditInput) -> str: +def format_multiedit_input(multiedit_input: MultiEditInput) -> str: """Format Multiedit tool use content showing multiple diffs. Args: @@ -482,7 +479,7 @@ def format_multiedit_tool_content(multiedit_input: MultiEditInput) -> str: # -- Bash Tool ---------------------------------------------------------------- -def format_bash_tool_content(bash_input: BashInput) -> str: +def format_bash_input(bash_input: BashInput) -> str: """Format Bash tool use content in VS Code extension style. Args: @@ -501,7 +498,7 @@ def format_bash_tool_content(bash_input: BashInput) -> str: # -- Task Tool ---------------------------------------------------------------- -def format_task_tool_content(task_input: TaskInput) -> str: +def format_task_input(task_input: TaskInput) -> str: """Format Task tool content with markdown-rendered prompt. Args: @@ -669,55 +666,10 @@ def render_params_table(params: dict[str, Any]) -> str: return "".join(html_parts) -# -- Tool Use Dispatcher ------------------------------------------------------ +# -- Tool Result Content Fallback Formatter ----------------------------------- -# Registry mapping input types to their formatters -TOOL_USE_FORMATTERS: dict[type, Callable[[Any], str]] = { - TodoWriteInput: format_todowrite_content, - BashInput: format_bash_tool_content, - EditInput: format_edit_tool_content, - MultiEditInput: format_multiedit_tool_content, - WriteInput: format_write_tool_content, - TaskInput: format_task_tool_content, - ReadInput: format_read_tool_content, - AskUserQuestionInput: format_askuserquestion_content, - ExitPlanModeInput: format_exitplanmode_content, -} - -def format_tool_use_content(content: ToolUseMessage) -> str: - """Format ToolUseMessage as HTML. - - Dispatches to specialized formatters based on the parsed input type. - Falls back to rendering the raw input dict if parsing was incomplete. - - Args: - content: ToolUseMessage with parsed input and metadata - - Returns: - HTML string for the tool use content - """ - parsed_input = content.input - - # Dispatch based on parsed type via registry - if formatter := TOOL_USE_FORMATTERS.get(type(parsed_input)): - return formatter(parsed_input) - - # Fallback: ToolUseContent - render its input dict as params table - if isinstance(parsed_input, ToolUseContent): - return render_params_table(parsed_input.input) - - # Last resort: string representation (shouldn't happen with ToolInput union) - return f"
{parsed_input}
" - - -# -- Tool Result Content Formatter ------------------------------------------- - - -def _format_raw_tool_result( - tool_result: ToolResultContent, - tool_name: Optional[str] = None, # noqa: ARG001 -) -> str: +def format_tool_result_content_raw(tool_result: ToolResultContent) -> str: """Format raw ToolResultContent as HTML (fallback formatter). This handles tool results that don't have specialized output types, @@ -725,7 +677,6 @@ def _format_raw_tool_result( Args: tool_result: The raw tool result content - tool_name: Unused (kept for API compatibility) """ # Handle both string and structured content if isinstance(tool_result.content, str): @@ -830,71 +781,35 @@ def _format_raw_tool_result( """ -# Registry mapping output types to their formatters -TOOL_RESULT_FORMATTERS: dict[type, Callable[[Any], str]] = { - ReadOutput: format_read_tool_result, - EditOutput: format_edit_tool_result, - WriteOutput: format_write_tool_result, - BashOutput: format_bash_tool_result, - TaskOutput: format_task_tool_result, - AskUserQuestionOutput: format_askuserquestion_output, - ExitPlanModeOutput: format_exitplanmode_output, -} - - -def format_tool_result_content(content: ToolResultMessage) -> str: - """Format ToolResultMessage as HTML. - - Dispatches to specialized formatters based on the parsed output type. - Falls back to _format_raw_tool_result if output is unparsed ToolResultContent. - - Args: - content: ToolResultMessage with parsed output and metadata - - Returns: - HTML string for the tool result content - """ - output = content.output - - # Dispatch based on parsed output type via registry - if formatter := TOOL_RESULT_FORMATTERS.get(type(output)): - return formatter(output) - - # Fallback: raw ToolResultContent (cast is safe - registry handles other types) - return _format_raw_tool_result(cast(ToolResultContent, output), content.tool_name) - - # -- Public Exports ----------------------------------------------------------- __all__ = [ - # AskUserQuestion - "format_askuserquestion_content", + # Tool input formatters (called by HtmlRenderer.format_{InputClass}) + "format_askuserquestion_input", + "format_exitplanmode_input", + "format_todowrite_input", + "format_read_input", + "format_write_input", + "format_edit_input", + "format_multiedit_input", + "format_bash_input", + "format_task_input", + # Tool output formatters (called by HtmlRenderer.format_{OutputClass}) + "format_read_output", + "format_write_output", + "format_edit_output", + "format_bash_output", + "format_task_output", + "format_askuserquestion_output", + "format_exitplanmode_output", + # Fallback for ToolResultContent + "format_tool_result_content_raw", + # Legacy formatters (still used) "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) - parsing now in factories/tool_factory.py - "format_read_tool_result", - "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/renderer.py b/claude_code_log/renderer.py index 121dadf7..7c5701ae 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -18,6 +18,26 @@ HookSummaryMessage, ThinkingMessage, UserMemoryMessage, + # Tool input types + BashInput, + ReadInput, + WriteInput, + EditInput, + MultiEditInput, + GlobInput, + GrepInput, + TaskInput, + TodoWriteInput, + AskUserQuestionInput, + ExitPlanModeInput, + # Tool output types + ReadOutput, + WriteOutput, + EditOutput, + BashOutput, + TaskOutput, + AskUserQuestionOutput, + ExitPlanModeOutput, ) from datetime import datetime @@ -2195,14 +2215,174 @@ def format_UnknownMessage(self, message: "UnknownMessage") -> str: def format_ToolUseMessage(self, message: "ToolUseMessage") -> str: """Format ToolUseMessage content (tool invocations). - Fallback: None (standalone content type). + Dispatches to format_{InputClass} methods based on message.input type. + Walks the input type's MRO to find the most specific format method. """ + for cls in type(message.input).__mro__: + if cls is object: + break + if method := getattr(self, f"format_{cls.__name__}", None): + return method(message.input) return "" def format_ToolResultMessage(self, message: "ToolResultMessage") -> str: """Format ToolResultMessage content (tool results). - Fallback: None (standalone content type). + Dispatches to format_{OutputClass} methods based on message.output type. + Walks the output type's MRO to find the most specific format method. + """ + for cls in type(message.output).__mro__: + if cls is object: + break + if method := getattr(self, f"format_{cls.__name__}", None): + return method(message.output) + return "" + + # ------------------------------------------------------------------------- + # Tool Input Formatters + # ------------------------------------------------------------------------- + + def format_BashInput(self, input: "BashInput") -> str: + """Format BashInput (bash command invocation). + + Fallback: None. + """ + return "" + + def format_ReadInput(self, input: "ReadInput") -> str: + """Format ReadInput (file read request). + + Fallback: None. + """ + return "" + + def format_WriteInput(self, input: "WriteInput") -> str: + """Format WriteInput (file write request). + + Fallback: None. + """ + return "" + + def format_EditInput(self, input: "EditInput") -> str: + """Format EditInput (file edit request). + + Fallback: None. + """ + return "" + + def format_MultiEditInput(self, input: "MultiEditInput") -> str: + """Format MultiEditInput (multi-file edit request). + + Fallback: None. + """ + return "" + + def format_GlobInput(self, input: "GlobInput") -> str: + """Format GlobInput (file glob search). + + Fallback: None. + """ + return "" + + def format_GrepInput(self, input: "GrepInput") -> str: + """Format GrepInput (content search). + + Fallback: None. + """ + return "" + + def format_TaskInput(self, input: "TaskInput") -> str: + """Format TaskInput (sub-agent invocation). + + Fallback: None. + """ + return "" + + def format_TodoWriteInput(self, input: "TodoWriteInput") -> str: + """Format TodoWriteInput (todo list update). + + Fallback: None. + """ + return "" + + def format_AskUserQuestionInput(self, input: "AskUserQuestionInput") -> str: + """Format AskUserQuestionInput (user question prompt). + + Fallback: None. + """ + return "" + + def format_ExitPlanModeInput(self, input: "ExitPlanModeInput") -> str: + """Format ExitPlanModeInput (plan mode exit). + + Fallback: None. + """ + return "" + + def format_ToolUseContent(self, input: "ToolUseContent") -> str: + """Format ToolUseContent (generic/unknown tool invocation). + + Fallback: None (base handler for unknown tools). + """ + return "" + + # ------------------------------------------------------------------------- + # Tool Output Formatters + # ------------------------------------------------------------------------- + + def format_ReadOutput(self, output: "ReadOutput") -> str: + """Format ReadOutput (file read result). + + Fallback: None. + """ + return "" + + def format_WriteOutput(self, output: "WriteOutput") -> str: + """Format WriteOutput (file write result). + + Fallback: None. + """ + return "" + + def format_EditOutput(self, output: "EditOutput") -> str: + """Format EditOutput (file edit result). + + Fallback: None. + """ + return "" + + def format_BashOutput(self, output: "BashOutput") -> str: + """Format BashOutput (bash command result). + + Fallback: None. + """ + return "" + + def format_TaskOutput(self, output: "TaskOutput") -> str: + """Format TaskOutput (sub-agent result). + + Fallback: None. + """ + return "" + + def format_AskUserQuestionOutput(self, output: "AskUserQuestionOutput") -> str: + """Format AskUserQuestionOutput (user question result). + + Fallback: None. + """ + return "" + + def format_ExitPlanModeOutput(self, output: "ExitPlanModeOutput") -> str: + """Format ExitPlanModeOutput (plan mode exit result). + + Fallback: None. + """ + return "" + + def format_ToolResultContent(self, output: "ToolResultContent") -> str: + """Format ToolResultContent (generic/unknown tool result). + + Fallback: None (base handler for unknown tools). """ return "" diff --git a/test/test_askuserquestion_rendering.py b/test/test_askuserquestion_rendering.py index 3d8a3acc..a77f7f98 100644 --- a/test/test_askuserquestion_rendering.py +++ b/test/test_askuserquestion_rendering.py @@ -2,7 +2,7 @@ """Test cases for AskUserQuestion tool rendering.""" from claude_code_log.html import ( - format_askuserquestion_content, + format_askuserquestion_input, format_askuserquestion_result, ) from claude_code_log.models import ( @@ -52,7 +52,7 @@ def test_format_askuserquestion_multiple_questions(self): ] ) - html = format_askuserquestion_content(ask_input) + html = format_askuserquestion_input(ask_input) # Check overall structure assert 'class="askuserquestion-content"' in html @@ -104,7 +104,7 @@ def test_format_askuserquestion_single_question(self): ] ) - html = format_askuserquestion_content(ask_input) + html = format_askuserquestion_input(ask_input) # Check structure assert 'class="askuserquestion-content"' in html @@ -132,7 +132,7 @@ def test_format_askuserquestion_multiselect(self): ] ) - html = format_askuserquestion_content(ask_input) + html = format_askuserquestion_input(ask_input) # Check multi-select hint assert "(select multiple)" in html @@ -144,7 +144,7 @@ def test_format_askuserquestion_legacy_single_question(self): """Test backwards compatibility with single 'question' key format.""" ask_input = AskUserQuestionInput(question="What is your preference?") - html = format_askuserquestion_content(ask_input) + html = format_askuserquestion_input(ask_input) # Should still render the question assert 'class="askuserquestion-content"' in html @@ -162,7 +162,7 @@ def test_format_askuserquestion_no_options(self): ] ) - html = format_askuserquestion_content(ask_input) + html = format_askuserquestion_input(ask_input) # Should render without options list assert "Please describe the issue in detail." in html @@ -175,7 +175,7 @@ def test_format_askuserquestion_empty_input(self): """Test AskUserQuestion with empty questions returns 'No question' message.""" ask_input = AskUserQuestionInput() # Empty questions list - html = format_askuserquestion_content(ask_input) + html = format_askuserquestion_input(ask_input) # Should show 'No question' message assert "askuserquestion-content" in html @@ -197,7 +197,7 @@ def test_format_askuserquestion_escapes_html(self): ] ) - html = format_askuserquestion_content(ask_input) + html = format_askuserquestion_input(ask_input) # HTML entities should be escaped assert "<script>" in html diff --git a/test/test_bash_rendering.py b/test/test_bash_rendering.py index 9f2f07eb..318d8199 100644 --- a/test/test_bash_rendering.py +++ b/test/test_bash_rendering.py @@ -338,7 +338,7 @@ def test_bash_ansi_color_rendering(): def test_bash_tool_result_ansi_processing(): """Test that Bash tool results have ANSI codes processed.""" from claude_code_log.factories.tool_factory import _looks_like_bash_output - from claude_code_log.html.tool_formatters import format_bash_tool_result + from claude_code_log.html.tool_formatters import format_bash_output from claude_code_log.models import BashOutput # Test the detection function @@ -350,7 +350,7 @@ def test_bash_tool_result_ansi_processing(): # Test BashOutput formatting with ANSI codes bash_output = BashOutput(content=bash_content, has_ansi=True) - html = format_bash_tool_result(bash_output) + html = format_bash_output(bash_output) # Should contain colored output assert '✔ Build completed' in html @@ -362,14 +362,14 @@ def test_bash_tool_result_ansi_processing(): def test_bash_tool_result_cursor_stripping(): """Test that cursor movement codes are stripped from Bash tool results.""" - from claude_code_log.html.tool_formatters import format_bash_tool_result + from claude_code_log.html.tool_formatters import format_bash_output from claude_code_log.models import BashOutput # Content with cursor movement codes content_with_cursor = "Building...\x1b[1A\x1b[2K\x1b[32m✔ Done!\x1b[0m" bash_output = BashOutput(content=content_with_cursor, has_ansi=True) - html = format_bash_tool_result(bash_output) + html = format_bash_output(bash_output) # Should have colors but no cursor codes assert '✔ Done!' in html diff --git a/test/test_exitplanmode_rendering.py b/test/test_exitplanmode_rendering.py index 68d11893..808ac81c 100644 --- a/test/test_exitplanmode_rendering.py +++ b/test/test_exitplanmode_rendering.py @@ -2,7 +2,7 @@ """Test cases for ExitPlanMode tool rendering.""" from claude_code_log.html import ( - format_exitplanmode_content, + format_exitplanmode_input, format_exitplanmode_result, ) from claude_code_log.models import ExitPlanModeInput @@ -17,7 +17,7 @@ def test_format_exitplanmode_with_plan(self): plan="# My Plan\n\n## Overview\n\nThis is the plan.\n\n## Steps\n\n1. First step\n2. Second step" ) - html = format_exitplanmode_content(exit_input) + html = format_exitplanmode_input(exit_input) # Should render as markdown in a plan-content div assert 'class="plan-content' in html @@ -33,7 +33,7 @@ def test_format_exitplanmode_long_plan_collapsible(self): ) exit_input = ExitPlanModeInput(plan=long_plan) - html = format_exitplanmode_content(exit_input) + html = format_exitplanmode_input(exit_input) # Should be collapsible due to length assert "collapsible" in html.lower() or "details" in html.lower() @@ -43,7 +43,7 @@ def test_format_exitplanmode_empty_plan(self): """Test ExitPlanMode with empty plan shows 'No plan' message.""" exit_input = ExitPlanModeInput() # Empty plan - html = format_exitplanmode_content(exit_input) + html = format_exitplanmode_input(exit_input) # Should show 'No plan' message assert "plan-content" in html diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index 56262cc9..e3577e07 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -6,7 +6,8 @@ from pathlib import Path import pytest from claude_code_log.converter import convert_jsonl_to_html -from claude_code_log.html import format_todowrite_content, format_tool_use_content +from claude_code_log.html import format_todowrite_input +from claude_code_log.html.renderer import HtmlRenderer from claude_code_log.models import ( EditInput, MessageMeta, @@ -44,7 +45,7 @@ def test_format_todowrite_basic(self): ] ) - html = format_todowrite_content(todo_input) + html = format_todowrite_input(todo_input) # Check overall structure (TodoWrite now has streamlined format) assert 'class="todo-list"' in html @@ -75,7 +76,7 @@ def test_format_todowrite_empty(self): """Test TodoWrite formatting with no todos.""" todo_input = TodoWriteInput(todos=[]) - html = format_todowrite_content(todo_input) + html = format_todowrite_input(todo_input) assert 'class="todo-content"' in html # Title and ID are now in the message header, not in content @@ -94,7 +95,7 @@ def test_format_todowrite_html_escaping(self): ] ) - html = format_todowrite_content(todo_input) + html = format_todowrite_input(todo_input) # Check that HTML is escaped assert "<script>" in html @@ -116,7 +117,7 @@ def test_format_todowrite_invalid_status_priority(self): ] ) - html = format_todowrite_content(todo_input) + html = format_todowrite_input(todo_input) # Should use default emojis for unknown values assert "⏳" in html # default status emoji @@ -227,9 +228,10 @@ def test_todowrite_vs_regular_tool_use(self): tool_name="TodoWrite", ) - # Test both through the main format function - regular_html = format_tool_use_content(regular_tool) - todowrite_html = format_tool_use_content(todowrite_tool) + # Test both through the HtmlRenderer + renderer = HtmlRenderer() + regular_html = renderer.format_ToolUseMessage(regular_tool) + todowrite_html = renderer.format_ToolUseMessage(todowrite_tool) # Edit tool should use diff formatting (not table) assert "edit-diff" in regular_html diff --git a/test/test_tool_result_image_rendering.py b/test/test_tool_result_image_rendering.py index 6f82f34a..b3b9b999 100644 --- a/test/test_tool_result_image_rendering.py +++ b/test/test_tool_result_image_rendering.py @@ -1,6 +1,6 @@ """Test image rendering within tool results.""" -from claude_code_log.html.tool_formatters import _format_raw_tool_result +from claude_code_log.html.tool_formatters import format_tool_result_content_raw from claude_code_log.models import ToolResultContent @@ -27,7 +27,7 @@ def test_tool_result_with_image(): is_error=False, ) - html = _format_raw_tool_result(tool_result) + html = format_tool_result_content_raw(tool_result) # Should be collapsible when images are present assert '
' in html @@ -66,7 +66,7 @@ def test_tool_result_with_only_image(): is_error=False, ) - html = _format_raw_tool_result(tool_result) + html = format_tool_result_content_raw(tool_result) # Should be collapsible assert '
' in html @@ -106,7 +106,7 @@ def test_tool_result_with_multiple_images(): is_error=False, ) - html = _format_raw_tool_result(tool_result) + html = format_tool_result_content_raw(tool_result) # Should contain both images assert html.count("' not in html @@ -145,7 +145,7 @@ def test_tool_result_structured_text_only(): is_error=False, ) - html = _format_raw_tool_result(tool_result) + html = format_tool_result_content_raw(tool_result) # Should contain both text lines assert "First line" in html From 737f51696e2c73ae88a9777fc86d94130def586a Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 21:31:33 +0100 Subject: [PATCH 43/57] Extract _dispatch_format helper for MRO-based method dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the repeated MRO-walking dispatch logic from three methods (format_content, format_ToolUseMessage, format_ToolResultMessage) into a single _dispatch_format helper method. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 43 +++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 7c5701ae..5c2e7cfa 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2064,26 +2064,39 @@ class Renderer: - Subclasses override methods to implement format-specific rendering """ - def format_content(self, message: "TemplateMessage") -> str: - """Format message content by dispatching to type-specific method. + def _dispatch_format(self, obj: Any) -> str: + """Dispatch to format_{ClassName} method based on object type. - Looks for a method named format_{ClassName} (e.g., format_SystemMessage). - Walks the content type's MRO to find the most specific format method. + Walks the object's type MRO to find the most specific format method. This allows methods for parent classes to serve as fallbacks. Args: - message: TemplateMessage with content to format. + obj: Object to format (content, input, or output). Returns: Formatted string (e.g., HTML), or empty string if no handler found. """ - for cls in type(message.content).__mro__: + for cls in type(obj).__mro__: if cls is object: break if method := getattr(self, f"format_{cls.__name__}", None): - return method(message.content) + return method(obj) return "" + def format_content(self, message: "TemplateMessage") -> str: + """Format message content by dispatching to type-specific method. + + Looks for a method named format_{ClassName} (e.g., format_SystemMessage). + Walks the content type's MRO to find the most specific format method. + + Args: + message: TemplateMessage with content to format. + + Returns: + Formatted string (e.g., HTML), or empty string if no handler found. + """ + return self._dispatch_format(message.content) + # ------------------------------------------------------------------------- # System Content Formatters # ------------------------------------------------------------------------- @@ -2216,27 +2229,15 @@ def format_ToolUseMessage(self, message: "ToolUseMessage") -> str: """Format ToolUseMessage content (tool invocations). Dispatches to format_{InputClass} methods based on message.input type. - Walks the input type's MRO to find the most specific format method. """ - for cls in type(message.input).__mro__: - if cls is object: - break - if method := getattr(self, f"format_{cls.__name__}", None): - return method(message.input) - return "" + return self._dispatch_format(message.input) def format_ToolResultMessage(self, message: "ToolResultMessage") -> str: """Format ToolResultMessage content (tool results). Dispatches to format_{OutputClass} methods based on message.output type. - Walks the output type's MRO to find the most specific format method. """ - for cls in type(message.output).__mro__: - if cls is object: - break - if method := getattr(self, f"format_{cls.__name__}", None): - return method(message.output) - return "" + return self._dispatch_format(message.output) # ------------------------------------------------------------------------- # Tool Input Formatters From d5e503b5fe1436c207cba54782e27be0ebdda7a3 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 22:52:30 +0100 Subject: [PATCH 44/57] Move token_usage formatting from renderer to content models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add token_usage field to AssistantTextMessage and ThinkingMessage - Create format_token_usage() helper in assistant_factory.py - Update create_assistant_message and create_thinking_message to accept optional UsageInfo and format it during creation - Replace TemplateMessage.token_usage parameter with property accessor that delegates to content.token_usage (template compatibility) - ThinkingMessage now also shows token usage (was missing before) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../factories/assistant_factory.py | 38 ++++++++++++++- claude_code_log/models.py | 6 +++ claude_code_log/renderer.py | 48 ++++++++----------- 3 files changed, 63 insertions(+), 29 deletions(-) diff --git a/claude_code_log/factories/assistant_factory.py b/claude_code_log/factories/assistant_factory.py index 90cf1909..3f8b977d 100644 --- a/claude_code_log/factories/assistant_factory.py +++ b/claude_code_log/factories/assistant_factory.py @@ -15,9 +15,35 @@ TextContent, ThinkingContent, ThinkingMessage, + UsageInfo, ) +# ============================================================================= +# Token Usage Formatting +# ============================================================================= + + +def format_token_usage(usage: UsageInfo) -> str: + """Format token usage information as a display string. + + Args: + usage: UsageInfo object with token counts. + + Returns: + Formatted string like "Input: 100 | Output: 50 | Cache Read: 25" + """ + token_parts = [ + f"Input: {usage.input_tokens}", + f"Output: {usage.output_tokens}", + ] + if usage.cache_creation_input_tokens: + token_parts.append(f"Cache Creation: {usage.cache_creation_input_tokens}") + if usage.cache_read_input_tokens: + token_parts.append(f"Cache Read: {usage.cache_read_input_tokens}") + return " | ".join(token_parts) + + # ============================================================================= # Message Creation Functions # ============================================================================= @@ -26,6 +52,7 @@ def create_assistant_message( meta: MessageMeta, items: list[ContentItem], + usage: Optional[UsageInfo] = None, ) -> Optional[AssistantTextMessage]: """Create AssistantTextMessage from content items. @@ -34,6 +61,7 @@ def create_assistant_message( Args: meta: Message metadata. items: List of text/image content items (no tool_use, tool_result, thinking). + usage: Optional token usage info to format and attach. Returns: AssistantTextMessage if items is non-empty, None otherwise. @@ -49,6 +77,7 @@ def create_assistant_message( meta, items=items, # type: ignore[arg-type] raw_text_content=text_content if text_content else None, + token_usage=format_token_usage(usage) if usage else None, ) return None @@ -56,12 +85,14 @@ def create_assistant_message( def create_thinking_message( meta: MessageMeta, tool_item: ContentItem, + usage: Optional[UsageInfo] = None, ) -> ThinkingMessage: """Create ThinkingMessage from a thinking content item. Args: meta: Message metadata. tool_item: ThinkingContent or compatible object with 'thinking' attribute + usage: Optional token usage info to format and attach. Returns: ThinkingMessage containing the thinking text and optional signature. @@ -75,4 +106,9 @@ def create_thinking_message( signature = None # Create the content model (formatting happens in HtmlRenderer) - return ThinkingMessage(meta, thinking=thinking_text, signature=signature) + return ThinkingMessage( + meta, + thinking=thinking_text, + signature=signature, + token_usage=format_token_usage(usage) if usage else None, + ) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index fb72278c..c20da9e3 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -624,6 +624,9 @@ class AssistantTextMessage(MessageContent): # Cached raw text extracted from items (for dedup matching, simple renderers) raw_text_content: Optional[str] = None + # Token usage string (formatted from UsageInfo when available) + token_usage: Optional[str] = None + @property def message_type(self) -> str: return "assistant" @@ -650,6 +653,9 @@ class ThinkingMessage(MessageContent): thinking: str signature: Optional[str] = None + # Token usage string (formatted from UsageInfo when available) + token_usage: Optional[str] = None + @property def message_type(self) -> str: return "thinking" diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 5c2e7cfa..ec8ff90d 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -54,6 +54,7 @@ ToolResultContent, ToolUseContent, ThinkingContent, + UsageInfo, # Structured content types AssistantTextMessage, CommandOutputMessage, @@ -101,12 +102,12 @@ class TemplateMessage: """Structured message data for template rendering. This is a lightweight wrapper around MessageContent that adds: - - Rendering metadata (message_id, ancestry, token_usage) + - Rendering metadata (message_id, ancestry) - Tree structure (children, fold/unfold counts) - Pairing metadata (is_paired, pair_role, pair_duration) All identity/context fields come from meta (timestamp, session_id, etc.) - and content (tool_use_id, has_markdown, etc.). + and content (tool_use_id, has_markdown, token_usage, etc.). """ def __init__( @@ -115,7 +116,6 @@ def __init__( meta: "MessageMeta", *, # Force keyword arguments after this message_title: Optional[str] = None, - token_usage: Optional[str] = None, message_id: Optional[str] = None, ancestry: Optional[list[str]] = None, uuid: Optional[str] = None, @@ -133,7 +133,6 @@ def __init__( ) # Rendering metadata - self.token_usage = token_usage self.message_id = message_id self.ancestry = ancestry or [] # uuid can differ from meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") @@ -193,6 +192,11 @@ def agent_id(self) -> Optional[str]: """Get agent_id from meta.""" return self.meta.agent_id + @property + def token_usage(self) -> Optional[str]: + """Get token_usage from content (if available).""" + return getattr(self.content, "token_usage", None) + @property def is_sidechain(self) -> bool: """Check if this is a sidechain message.""" @@ -1814,28 +1818,15 @@ def _render_messages( # 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 + usage_to_show: Optional[UsageInfo] = None if assistant_entry := as_assistant_entry(message): assistant_message = assistant_entry.message message_uuid = assistant_entry.uuid - 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 = [ - f"Input: {usage.input_tokens}", - f"Output: {usage.output_tokens}", - ] - if usage.cache_creation_input_tokens: - token_parts.append( - f"Cache Creation: {usage.cache_creation_input_tokens}" - ) - if usage.cache_read_input_tokens: - token_parts.append(f"Cache Read: {usage.cache_read_input_tokens}") - token_usage_str = " | ".join(token_parts) + usage_to_show = assistant_message.usage - # Track whether we've shown token usage (only show on first content chunk) - token_shown = False + # Track whether we've used the usage (only use on first content chunk) + usage_used = False # Process each chunk - regular chunks (list) become text/image messages, # special chunks (single item) become tool/thinking messages @@ -1856,7 +1847,10 @@ def _render_messages( is_slash_command=meta.is_meta, ) elif effective_type == "assistant": - content_model = create_assistant_message(meta, chunk) + # Pass usage only on first chunk + chunk_usage = usage_to_show if not usage_used else None + usage_used = True + content_model = create_assistant_message(meta, chunk, chunk_usage) # Convert to UserSteeringMessage for queue-operation 'remove' messages if ( @@ -1880,15 +1874,10 @@ def _render_messages( ): message_title = "Sub-assistant" - # Only show token usage on first chunk - chunk_token_usage = token_usage_str if not token_shown else None - token_shown = True - template_message = TemplateMessage( content_model, meta, message_title=message_title, - token_usage=chunk_token_usage, ) template_messages.append(template_message) @@ -1908,7 +1897,10 @@ def _render_messages( meta, tool_item, tool_use_context ) elif isinstance(tool_item, ThinkingContent): - content = create_thinking_message(meta, tool_item) + # Pass usage only if not yet used + chunk_usage = usage_to_show if not usage_used else None + usage_used = True + content = create_thinking_message(meta, tool_item, chunk_usage) tool_result = ToolItemResult( message_type=content.message_type, message_title=content.message_title() or "Thinking", From f7d5c3679dcc92ffff85cf3df57cbb4481a0969b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 23:04:58 +0100 Subject: [PATCH 45/57] Refactor message_title to property with smart delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert TemplateMessage.message_title from stored field to property - Property priority: override -> sidechain check -> content.message_title() -> type fallback - Remove explicit message_title= from TemplateMessage constructors where not needed - Add message_title() to UnknownMessage returning "Unknown Content" - Move sidechain "Sub-assistant" title logic into the property - Update snapshot for edge case that now shows "User" instead of empty title 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 3 ++ claude_code_log/renderer.py | 52 +++++++++++----------- test/__snapshots__/test_snapshot_html.ambr | 2 +- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index c20da9e3..16316989 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -686,6 +686,9 @@ class UnknownMessage(MessageContent): def message_type(self) -> str: return "unknown" + def message_title(self) -> Optional[str]: + return "Unknown Content" + # ============================================================================= # Renderer Content Models diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index ec8ff90d..824d5b5d 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -124,13 +124,8 @@ def __init__( self.content = content self.meta = meta - # Display title for message header (capitalized, with decorations) - # Falls back to content.message_type if not provided - self.message_title = ( - message_title - if message_title is not None - else content.message_type.replace("_", " ").replace("-", " ").title() - ) + # Optional title override (for tool messages with HTML-formatted titles) + self._message_title_override = message_title # Rendering metadata self.message_id = message_id @@ -162,6 +157,27 @@ def type(self) -> str: """Get message type from content.""" return self.content.message_type + @property + def message_title(self) -> str: + """Get message title (override, content.message_title(), or type-based fallback). + + Priority: + 1. Explicit override (for tool messages with HTML-formatted titles) + 2. Sidechain assistant -> "Sub-assistant" + 3. content.message_title() if not None + 4. Type-based fallback from message_type + """ + if self._message_title_override is not None: + return self._message_title_override + # Sidechain assistant messages get special title + if self.meta.is_sidechain and isinstance(self.content, AssistantTextMessage): + return "Sub-assistant" + # Try content's message_title method + if title := self.content.message_title(): + return title + # Fallback: convert message_type to title case + return self.content.message_type.replace("_", " ").replace("-", " ").title() + @property def is_session_header(self) -> bool: """Check if this message is a session header.""" @@ -1739,11 +1755,7 @@ def _render_messages( system_content = create_system_message(message) if system_content: template_messages.append( - TemplateMessage( - system_content, - system_content.meta, - message_title=system_content.message_title() or "System", - ) + TemplateMessage(system_content, system_content.meta) ) continue @@ -1866,21 +1878,7 @@ def _render_messages( if not chunk or content_model is None: continue - # Get message_title from content_model - message_title = content_model.message_title() - # Override for sidechain assistant messages - if meta.is_sidechain and isinstance( - content_model, AssistantTextMessage - ): - message_title = "Sub-assistant" - - template_message = TemplateMessage( - content_model, - meta, - message_title=message_title, - ) - - template_messages.append(template_message) + template_messages.append(TemplateMessage(content_model, meta)) else: # Special chunk: single tool_use/tool_result/thinking item diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 4f293426..cfd287fe 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -9846,7 +9846,7 @@
- + User
2025-06-14 11:02:20 From 85340e32a900154015bc6567b519da482a73218d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 25 Dec 2025 23:31:01 +0100 Subject: [PATCH 46/57] Move title generation to renderer with title_* dispatch methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add title_content() dispatcher that walks MRO for title_{ClassName} - Add title_ToolUseMessage/title_ToolResultMessage to HtmlRenderer - Update _flatten_preorder to return (message, title, html, timestamp) - Update template to unpack and use title separately from message - Simplify ToolItemResult by removing message_title/title_hint fields - Compact Renderer stub methods to single-line comment hints - Remove unused TYPE_CHECKING imports (used by compacted stubs) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/factories/tool_factory.py | 34 +- claude_code_log/html/renderer.py | 34 +- .../html/templates/transcript.html | 8 +- claude_code_log/renderer.py | 404 ++++-------------- test/__snapshots__/test_snapshot_html.ambr | 12 +- 5 files changed, 126 insertions(+), 366 deletions(-) diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 0449e2a2..74e00f8d 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -52,7 +52,6 @@ ToolOutput, WriteOutput, ) -from ..html import escape_html, format_tool_use_title # ============================================================================= @@ -637,13 +636,14 @@ def create_tool_output( @dataclass class ToolItemResult: - """Result of processing a single tool/thinking/image item.""" + """Result of processing a single tool/thinking/image item. + + Note: Titles are computed at render time by Renderer.title_content() dispatch. + """ 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 is_error: bool = False # For tool_result error state @@ -655,25 +655,18 @@ def create_tool_use_message( """Create ToolItemResult from a tool_use content item. Args: + meta: Message metadata tool_use: The tool use content item tool_use_context: Dict to populate with tool_use_id -> ToolUseContent mapping - meta: Message metadata Returns: ToolItemResult with tool_use content model """ - - # Parse tool input once, use for both title and message content + # Parse tool input into typed model (BashInput, ReadInput, etc.) parsed = create_tool_input(tool_use.name, tool_use.input) - # Title is computed here but content formatting happens in HtmlRenderer - tool_message_title = format_tool_use_title(tool_use.name, parsed) - 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 + tool_use_context[tool_use.id] = tool_use # Create ToolUseMessage wrapper with parsed input for specialized formatting # Use ToolUseContent as fallback when no specialized parser exists @@ -686,10 +679,8 @@ def create_tool_use_message( return ToolItemResult( message_type="tool_use", - message_title=tool_message_title, content=tool_use_message, - tool_use_id=item_tool_use_id, - title_hint=tool_title_hint, + tool_use_id=tool_use.id, ) @@ -701,14 +692,13 @@ def create_tool_result_message( """Create ToolItemResult from a tool_result content item. Args: + meta: Message metadata tool_result: The tool result content item tool_use_context: Dict with tool_use_id -> ToolUseContent mapping - meta: Message metadata Returns: ToolItemResult with tool_result content model """ - # 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 @@ -738,15 +728,9 @@ def create_tool_result_message( file_path=result_file_path, ) - 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, is_error=tool_result.is_error or False, ) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index c603dde5..acd5b25a 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -16,6 +16,8 @@ SlashCommandMessage, SystemMessage, ThinkingMessage, + ToolResultMessage, + ToolUseMessage, TranscriptEntry, UnknownMessage, UserMemoryMessage, @@ -93,6 +95,7 @@ format_task_output, format_todowrite_input, format_tool_result_content_raw, + format_tool_use_title, format_write_input, format_write_output, render_params_table, @@ -259,16 +262,34 @@ def format_ExitPlanModeOutput(self, output: ExitPlanModeOutput) -> str: def format_ToolResultContent(self, output: ToolResultContent) -> str: return format_tool_result_content_raw(output) + # ------------------------------------------------------------------------- + # Title Methods (for Renderer.title_content dispatch) + # ------------------------------------------------------------------------- + + def title_ToolUseMessage(self, message: TemplateMessage) -> str: + """Generate HTML title for tool use messages.""" + content = message.content + if isinstance(content, ToolUseMessage): + return format_tool_use_title(content.tool_name, content.input) + return message.message_title + + def title_ToolResultMessage(self, message: TemplateMessage) -> str: + """Generate title for tool result messages.""" + content = message.content + if isinstance(content, ToolResultMessage): + return "Error" if content.is_error else "Tool Result" + return message.message_title + def _flatten_preorder( self, roots: list[TemplateMessage] ) -> Tuple[ - list[Tuple[TemplateMessage, str, str]], + list[Tuple[TemplateMessage, str, str, str]], list[Tuple[str, list[Tuple[float, 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, timestamp) tuples. + Traverses the tree depth-first (pre-order), computes title and formats + content to HTML, building a flat list of (message, title, html, timestamp) tuples. Also tracks timing statistics for Markdown and Pygments operations when DEBUG_TIMING is enabled. @@ -278,10 +299,10 @@ def _flatten_preorder( Returns: Tuple of: - - Flat list of (message, html_content, formatted_timestamp) tuples in pre-order + - Flat list of (message, title, html_content, formatted_timestamp) tuples - Operation timing data for reporting: [("Markdown", timings), ("Pygments", timings)] """ - flat: list[Tuple[TemplateMessage, str, str]] = [] + flat: list[Tuple[TemplateMessage, str, str, str]] = [] # Initialize timing tracking for expensive operations markdown_timings: list[Tuple[float, str]] = [] @@ -292,9 +313,10 @@ def _flatten_preorder( def visit(msg: TemplateMessage) -> None: # Update current message UUID for timing tracking set_timing_var("_current_msg_uuid", msg.uuid) + title = self.title_content(msg) html = self.format_content(msg) formatted_ts = format_timestamp(msg.meta.timestamp if msg.meta else None) - flat.append((msg, html, formatted_ts)) + flat.append((msg, title, html, formatted_ts)) for child in msg.children: visit(child) diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 20174d68..ff8d6441 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -69,7 +69,7 @@

🔍 Search & Filter

{{ render_session_nav(sessions, "toc") }} {% endif %} - {% for message, html_content, formatted_timestamp in messages %} + {% for message, message_title, html_content, formatted_timestamp in messages %} {% if is_session_header(message) %}
@@ -102,9 +102,9 @@

🔍 Search & Filter

{% set msg_emoji = get_message_emoji(message) -%} - {% if message.message_title %}{% - if message.message_title == 'Memory' %}💭 {% - elif msg_emoji and (message.type != 'tool_use' or not starts_with_emoji(message.message_title)) %}{{ msg_emoji }} {% endif %}{{ message.message_title | safe }}{% endif %} + {% if message_title %}{% + if message_title == 'Memory' %}💭 {% + elif msg_emoji and (message.type != 'tool_use' or not starts_with_emoji(message_title)) %}{{ msg_emoji }} {% endif %}{{ message_title | safe }}{% endif %}
{{ formatted_timestamp }} diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 824d5b5d..c319d99b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -5,41 +5,12 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Optional, Tuple, TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, Optional, Tuple, cast +from datetime import datetime if TYPE_CHECKING: from .cache import CacheManager - from .models import ( - MessageContent, - # For formatter method type hints - BashInputMessage, - BashOutputMessage, - CompactedSummaryMessage, - HookSummaryMessage, - ThinkingMessage, - UserMemoryMessage, - # Tool input types - BashInput, - ReadInput, - WriteInput, - EditInput, - MultiEditInput, - GlobInput, - GrepInput, - TaskInput, - TodoWriteInput, - AskUserQuestionInput, - ExitPlanModeInput, - # Tool output types - ReadOutput, - WriteOutput, - EditOutput, - BashOutput, - TaskOutput, - AskUserQuestionOutput, - ExitPlanModeOutput, - ) -from datetime import datetime + from .models import MessageContent from .models import ( MessageMeta, @@ -115,7 +86,6 @@ def __init__( content: "MessageContent", meta: "MessageMeta", *, # Force keyword arguments after this - message_title: Optional[str] = None, message_id: Optional[str] = None, ancestry: Optional[list[str]] = None, uuid: Optional[str] = None, @@ -124,9 +94,6 @@ def __init__( self.content = content self.meta = meta - # Optional title override (for tool messages with HTML-formatted titles) - self._message_title_override = message_title - # Rendering metadata self.message_id = message_id self.ancestry = ancestry or [] @@ -159,16 +126,15 @@ def type(self) -> str: @property def message_title(self) -> str: - """Get message title (override, content.message_title(), or type-based fallback). + """Get message title from content or type-based fallback. Priority: - 1. Explicit override (for tool messages with HTML-formatted titles) - 2. Sidechain assistant -> "Sub-assistant" - 3. content.message_title() if not None - 4. Type-based fallback from message_type + 1. Sidechain assistant -> "Sub-assistant" + 2. content.message_title() if not None + 3. Type-based fallback from message_type + + Note: Renderer.title_content() may override this for specific content types. """ - if self._message_title_override is not None: - return self._message_title_override # Sidechain assistant messages get special title if self.meta.is_sidechain and isinstance(self.content, AssistantTextMessage): return "Sub-assistant" @@ -1901,7 +1867,6 @@ def _render_messages( content = create_thinking_message(meta, tool_item, chunk_usage) tool_result = ToolItemResult( message_type=content.message_type, - message_title=content.message_title() or "Thinking", content=content, ) else: @@ -1909,7 +1874,6 @@ def _render_messages( tool_result = ToolItemResult( message_type="unknown", content=UnknownMessage(meta, type_name=str(type(tool_item))), - message_title="Unknown Content", ) # Generate unique UUID for this tool message @@ -1925,15 +1889,10 @@ def _render_messages( if tool_result.content is None: continue - tool_template_message = TemplateMessage( - tool_result.content, - meta, - message_title=tool_result.message_title, - uuid=tool_uuid, + template_messages.append( + TemplateMessage(tool_result.content, meta, uuid=tool_uuid) ) - template_messages.append(tool_template_message) - return template_messages @@ -2087,295 +2046,90 @@ def format_content(self, message: "TemplateMessage") -> str: """ return self._dispatch_format(message.content) - # ------------------------------------------------------------------------- - # System Content Formatters - # ------------------------------------------------------------------------- - - def format_SystemMessage(self, message: "SystemMessage") -> str: - """Format SystemMessage content. - - Fallback: None (base handler for system messages). - """ - return "" - - def format_HookSummaryMessage(self, message: "HookSummaryMessage") -> str: - """Format HookSummaryMessage content (hook execution results). - - Fallback: format_SystemMessage (HookSummaryMessage is system-related). - """ - return self.format_SystemMessage(message) # type: ignore[arg-type] - - def format_SessionHeaderMessage(self, message: "SessionHeaderMessage") -> str: - """Format SessionHeaderMessage content (session start markers). - - Fallback: None (standalone content type). - """ - return "" - - def format_DedupNoticeMessage(self, message: "DedupNoticeMessage") -> str: - """Format DedupNoticeMessage content (duplicate content notices). - - Fallback: None (standalone content type). - """ - return "" - - # ------------------------------------------------------------------------- - # User Content Formatters - # ------------------------------------------------------------------------- - - def format_UserTextMessage(self, message: "UserTextMessage") -> str: - """Format UserTextMessage content (user input with text/images). - - Fallback: None (base handler for user text messages). - """ - return "" - - def format_UserSteeringMessage(self, message: "UserSteeringMessage") -> str: - """Format UserSteeringMessage content (out-of-band steering input). - - Fallback: format_UserTextMessage (UserSteeringMessage extends UserTextMessage). - """ - return self.format_UserTextMessage(message) - - def format_UserSlashCommandMessage(self, message: "UserSlashCommandMessage") -> str: - """Format UserSlashCommandMessage content (user slash commands). - - Fallback: format_UserTextMessage (similar content structure). - """ - return self.format_UserTextMessage(message) # type: ignore[arg-type] - - def format_SlashCommandMessage(self, message: "SlashCommandMessage") -> str: - """Format SlashCommandMessage content (system slash commands). - - Fallback: None (standalone content type). - """ - return "" - - def format_CommandOutputMessage(self, message: "CommandOutputMessage") -> str: - """Format CommandOutputMessage content (slash command output). - - Fallback: None (standalone content type). - """ - return "" - - def format_BashInputMessage(self, message: "BashInputMessage") -> str: - """Format BashInputMessage content (bash command input). + def title_content(self, message: "TemplateMessage") -> str: + """Get message title by dispatching to type-specific title method. - Fallback: None (standalone content type). - """ - return "" - - def format_BashOutputMessage(self, message: "BashOutputMessage") -> str: - """Format BashOutputMessage content (bash command output). - - Fallback: None (standalone content type). - """ - return "" + Looks for a method named title_{ClassName} (e.g., title_ToolUseMessage). + Falls back to content.message_title() or type-based title. - def format_CompactedSummaryMessage(self, message: "CompactedSummaryMessage") -> str: - """Format CompactedSummaryMessage content (context summaries). - - Fallback: None (standalone content type). - """ - return "" - - def format_UserMemoryMessage(self, message: "UserMemoryMessage") -> str: - """Format UserMemoryMessage content (memory/context updates). - - Fallback: None (standalone content type). - """ - return "" - - # ------------------------------------------------------------------------- - # Assistant Content Formatters - # ------------------------------------------------------------------------- - - def format_AssistantTextMessage(self, message: "AssistantTextMessage") -> str: - """Format AssistantTextMessage content (assistant responses). - - Fallback: None (base handler for assistant messages). - """ - return "" - - def format_ThinkingMessage(self, message: "ThinkingMessage") -> str: - """Format ThinkingMessage content (assistant reasoning). - - Fallback: None (standalone content type). - """ - return "" - - def format_UnknownMessage(self, message: "UnknownMessage") -> str: - """Format UnknownMessage content (unrecognized content types). + Args: + message: TemplateMessage to get title for. - Fallback: None (standalone content type). + Returns: + Title string for the message header. """ - return "" + # First try title_{ClassName} dispatch + for cls in type(message.content).__mro__: + if cls is object: + break + if method := getattr(self, f"title_{cls.__name__}", None): + return method(message) + # Fall back to the message_title property (content-based) + return message.message_title # ------------------------------------------------------------------------- - # Tool Content Formatters + # Format Method Stubs (override in subclasses) # ------------------------------------------------------------------------- - + # System content formatters + # def format_SystemMessage(self, message: "SystemMessage") -> str: return "" + # def format_HookSummaryMessage(self, message: "HookSummaryMessage") -> str: ... + # def format_SessionHeaderMessage(self, message: "SessionHeaderMessage") -> str: ... + # def format_DedupNoticeMessage(self, message: "DedupNoticeMessage") -> str: ... + + # User content formatters + # def format_UserTextMessage(self, message: "UserTextMessage") -> str: ... + # def format_UserSteeringMessage(self, message: "UserSteeringMessage") -> str: ... + # def format_UserSlashCommandMessage(self, message: "UserSlashCommandMessage") -> str: ... + # def format_SlashCommandMessage(self, message: "SlashCommandMessage") -> str: ... + # def format_CommandOutputMessage(self, message: "CommandOutputMessage") -> str: ... + # def format_BashInputMessage(self, message: "BashInputMessage") -> str: ... + # def format_BashOutputMessage(self, message: "BashOutputMessage") -> str: ... + # def format_CompactedSummaryMessage(self, message: "CompactedSummaryMessage") -> str: ... + # def format_UserMemoryMessage(self, message: "UserMemoryMessage") -> str: ... + + # Assistant content formatters + # def format_AssistantTextMessage(self, message: "AssistantTextMessage") -> str: ... + # def format_ThinkingMessage(self, message: "ThinkingMessage") -> str: ... + # def format_UnknownMessage(self, message: "UnknownMessage") -> str: ... + + # Tool content formatters (dispatch to input/output formatters) def format_ToolUseMessage(self, message: "ToolUseMessage") -> str: - """Format ToolUseMessage content (tool invocations). - - Dispatches to format_{InputClass} methods based on message.input type. - """ + """Dispatch to format_{InputClass} based on message.input type.""" return self._dispatch_format(message.input) def format_ToolResultMessage(self, message: "ToolResultMessage") -> str: - """Format ToolResultMessage content (tool results). - - Dispatches to format_{OutputClass} methods based on message.output type. - """ + """Dispatch to format_{OutputClass} based on message.output type.""" return self._dispatch_format(message.output) - # ------------------------------------------------------------------------- - # Tool Input Formatters - # ------------------------------------------------------------------------- - - def format_BashInput(self, input: "BashInput") -> str: - """Format BashInput (bash command invocation). - - Fallback: None. - """ - return "" - - def format_ReadInput(self, input: "ReadInput") -> str: - """Format ReadInput (file read request). - - Fallback: None. - """ - return "" - - def format_WriteInput(self, input: "WriteInput") -> str: - """Format WriteInput (file write request). - - Fallback: None. - """ - return "" - - def format_EditInput(self, input: "EditInput") -> str: - """Format EditInput (file edit request). - - Fallback: None. - """ - return "" - - def format_MultiEditInput(self, input: "MultiEditInput") -> str: - """Format MultiEditInput (multi-file edit request). - - Fallback: None. - """ - return "" - - def format_GlobInput(self, input: "GlobInput") -> str: - """Format GlobInput (file glob search). - - Fallback: None. - """ - return "" - - def format_GrepInput(self, input: "GrepInput") -> str: - """Format GrepInput (content search). - - Fallback: None. - """ - return "" - - def format_TaskInput(self, input: "TaskInput") -> str: - """Format TaskInput (sub-agent invocation). - - Fallback: None. - """ - return "" - - def format_TodoWriteInput(self, input: "TodoWriteInput") -> str: - """Format TodoWriteInput (todo list update). - - Fallback: None. - """ - return "" - - def format_AskUserQuestionInput(self, input: "AskUserQuestionInput") -> str: - """Format AskUserQuestionInput (user question prompt). - - Fallback: None. - """ - return "" - - def format_ExitPlanModeInput(self, input: "ExitPlanModeInput") -> str: - """Format ExitPlanModeInput (plan mode exit). - - Fallback: None. - """ - return "" - - def format_ToolUseContent(self, input: "ToolUseContent") -> str: - """Format ToolUseContent (generic/unknown tool invocation). - - Fallback: None (base handler for unknown tools). - """ - return "" + # Tool input formatters + # def format_BashInput(self, input: "BashInput") -> str: ... + # def format_ReadInput(self, input: "ReadInput") -> str: ... + # def format_WriteInput(self, input: "WriteInput") -> str: ... + # def format_EditInput(self, input: "EditInput") -> str: ... + # def format_MultiEditInput(self, input: "MultiEditInput") -> str: ... + # def format_GlobInput(self, input: "GlobInput") -> str: ... + # def format_GrepInput(self, input: "GrepInput") -> str: ... + # def format_TaskInput(self, input: "TaskInput") -> str: ... + # def format_TodoWriteInput(self, input: "TodoWriteInput") -> str: ... + # def format_AskUserQuestionInput(self, input: "AskUserQuestionInput") -> str: ... + # def format_ExitPlanModeInput(self, input: "ExitPlanModeInput") -> str: ... + # def format_ToolUseContent(self, input: "ToolUseContent") -> str: ... # fallback + + # Tool output formatters + # def format_ReadOutput(self, output: "ReadOutput") -> str: ... + # def format_WriteOutput(self, output: "WriteOutput") -> str: ... + # def format_EditOutput(self, output: "EditOutput") -> str: ... + # def format_BashOutput(self, output: "BashOutput") -> str: ... + # def format_TaskOutput(self, output: "TaskOutput") -> str: ... + # def format_AskUserQuestionOutput(self, output: "AskUserQuestionOutput") -> str: ... + # def format_ExitPlanModeOutput(self, output: "ExitPlanModeOutput") -> str: ... + # def format_ToolResultContent(self, output: "ToolResultContent") -> str: ... # fallback # ------------------------------------------------------------------------- - # Tool Output Formatters + # Title Method Stubs (override in subclasses for custom titles) # ------------------------------------------------------------------------- - - def format_ReadOutput(self, output: "ReadOutput") -> str: - """Format ReadOutput (file read result). - - Fallback: None. - """ - return "" - - def format_WriteOutput(self, output: "WriteOutput") -> str: - """Format WriteOutput (file write result). - - Fallback: None. - """ - return "" - - def format_EditOutput(self, output: "EditOutput") -> str: - """Format EditOutput (file edit result). - - Fallback: None. - """ - return "" - - def format_BashOutput(self, output: "BashOutput") -> str: - """Format BashOutput (bash command result). - - Fallback: None. - """ - return "" - - def format_TaskOutput(self, output: "TaskOutput") -> str: - """Format TaskOutput (sub-agent result). - - Fallback: None. - """ - return "" - - def format_AskUserQuestionOutput(self, output: "AskUserQuestionOutput") -> str: - """Format AskUserQuestionOutput (user question result). - - Fallback: None. - """ - return "" - - def format_ExitPlanModeOutput(self, output: "ExitPlanModeOutput") -> str: - """Format ExitPlanModeOutput (plan mode exit result). - - Fallback: None. - """ - return "" - - def format_ToolResultContent(self, output: "ToolResultContent") -> str: - """Format ToolResultContent (generic/unknown tool result). - - Fallback: None (base handler for unknown tools). - """ - return "" + # def title_ToolUseMessage(self, message: "TemplateMessage") -> str: ... + # def title_ToolResultMessage(self, message: "TemplateMessage") -> str: ... # ------------------------------------------------------------------------- # Rendering Entry Points diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index cfd287fe..32301472 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -4986,7 +4986,7 @@
- + 🧰 Tool Result
2025-07-03 15:58:07 @@ -5079,7 +5079,7 @@
- + 🧰 Tool Result
2025-07-03 16:06:07 @@ -14812,7 +14812,7 @@
- + 🧰 Tool Result
2025-07-03 15:58:07 @@ -14905,7 +14905,7 @@
- + 🧰 Tool Result
2025-07-03 16:06:07 @@ -19612,7 +19612,7 @@
- + 🧰 Tool Result
2025-07-03 15:58:07 @@ -19705,7 +19705,7 @@
- + 🧰 Tool Result
2025-07-03 16:06:07 From 9355dcf3b941f127922a44a2bcd744b90074cb5b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 26 Dec 2025 00:02:31 +0100 Subject: [PATCH 47/57] Move message titles from models to Renderer title_* methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove message_title() methods from all MessageContent subclasses - Remove TemplateMessage.message_title property - Add title_* methods to base Renderer for all content types - Add _dispatch_title() helper for secondary dispatch on tool inputs - Implement title_ToolUseMessage with secondary dispatch to title_{InputClass} - Add _tool_title() helper in HtmlRenderer for icon+summary formatting - Add 💻 terminal icon for Bash tool titles - Tool results return empty title (except "Error" for errors) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 66 ++++++++--- claude_code_log/models.py | 52 +-------- claude_code_log/renderer.py | 129 ++++++++++++++------- test/__snapshots__/test_snapshot_html.ambr | 20 ++-- test/test_template_data.py | 17 +-- 5 files changed, 158 insertions(+), 126 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index acd5b25a..ec278bcb 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -1,7 +1,7 @@ """HTML renderer implementation for Claude Code transcripts.""" from pathlib import Path -from typing import TYPE_CHECKING, Any, Optional, Tuple +from typing import TYPE_CHECKING, Any, Optional, Tuple, cast from ..cache import get_library_version from ..models import ( @@ -16,7 +16,6 @@ SlashCommandMessage, SystemMessage, ThinkingMessage, - ToolResultMessage, ToolUseMessage, TranscriptEntry, UnknownMessage, @@ -95,13 +94,13 @@ format_task_output, format_todowrite_input, format_tool_result_content_raw, - format_tool_use_title, format_write_input, format_write_output, render_params_table, ) from .utils import ( css_class_from_message, + escape_html, get_message_emoji, get_template_environment, is_session_header, @@ -263,22 +262,55 @@ def format_ToolResultContent(self, output: ToolResultContent) -> str: return format_tool_result_content_raw(output) # ------------------------------------------------------------------------- - # Title Methods (for Renderer.title_content dispatch) + # Tool Input Title Methods (for Renderer.title_ToolUseMessage dispatch) # ------------------------------------------------------------------------- - def title_ToolUseMessage(self, message: TemplateMessage) -> str: - """Generate HTML title for tool use messages.""" - content = message.content - if isinstance(content, ToolUseMessage): - return format_tool_use_title(content.tool_name, content.input) - return message.message_title - - def title_ToolResultMessage(self, message: TemplateMessage) -> str: - """Generate title for tool result messages.""" - content = message.content - if isinstance(content, ToolResultMessage): - return "Error" if content.is_error else "Tool Result" - return message.message_title + def _tool_title( + self, message: TemplateMessage, icon: str, summary: Optional[str] = None + ) -> str: + """Format tool title with icon and optional summary.""" + content = cast(ToolUseMessage, message.content) + escaped_name = escape_html(content.tool_name) + prefix = f"{icon} " if icon else "" + if summary: + escaped_summary = escape_html(summary) + return f"{prefix}{escaped_name} {escaped_summary}" + return f"{prefix}{escaped_name}" + + def title_TodoWriteInput(self, message: TemplateMessage) -> str: # noqa: ARG002 + return "📝 Todo List" + + def title_TaskInput(self, message: TemplateMessage) -> str: + content = cast(ToolUseMessage, message.content) + input = cast(TaskInput, content.input) + escaped_name = escape_html(content.tool_name) + escaped_subagent = ( + escape_html(input.subagent_type) if input.subagent_type else "" + ) + if input.description and input.subagent_type: + escaped_desc = escape_html(input.description) + return f"🔧 {escaped_name} {escaped_desc} ({escaped_subagent})" + elif input.description: + return self._tool_title(message, "🔧", input.description) + elif input.subagent_type: + return f"🔧 {escaped_name} ({escaped_subagent})" + return f"🔧 {escaped_name}" + + def title_EditInput(self, message: TemplateMessage) -> str: + input = cast(EditInput, cast(ToolUseMessage, message.content).input) + return self._tool_title(message, "📝", input.file_path) + + def title_WriteInput(self, message: TemplateMessage) -> str: + input = cast(WriteInput, cast(ToolUseMessage, message.content).input) + return self._tool_title(message, "📝", input.file_path) + + def title_ReadInput(self, message: TemplateMessage) -> str: + input = cast(ReadInput, cast(ToolUseMessage, message.content).input) + return self._tool_title(message, "📄", input.file_path) + + def title_BashInput(self, message: TemplateMessage) -> str: + input = cast(BashInput, cast(ToolUseMessage, message.content).input) + return self._tool_title(message, "💻", input.description) def _flatten_preorder( self, roots: list[TemplateMessage] diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 16316989..271ebed8 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -299,14 +299,6 @@ def message_type(self) -> str: """ raise NotImplementedError("Subclasses must implement message_type property") - def message_title(self) -> Optional[str]: - """Return a title for this message content, or None for default behavior. - - Subclasses can override this to provide a specific title that will be - used in the TemplateMessage wrapper. - """ - return None - @property def has_markdown(self) -> bool: """Whether this content should be rendered as markdown. @@ -330,10 +322,6 @@ class SystemMessage(MessageContent): def message_type(self) -> str: return "system" - def message_title(self) -> Optional[str]: - """Return 'System Info', 'System Warning', or 'System Error'.""" - return f"System {self.level.title()}" - @dataclass class HookInfo: @@ -358,10 +346,6 @@ class HookSummaryMessage(MessageContent): def message_type(self) -> str: return "system" - def message_title(self) -> Optional[str]: - """Return 'System Hook' for hook summary messages.""" - return "System Hook" - # ============================================================================= # User Message Content Models @@ -386,9 +370,6 @@ class SlashCommandMessage(MessageContent): def message_type(self) -> str: return "user" - def message_title(self) -> Optional[str]: - return "Slash Command" - @dataclass class CommandOutputMessage(MessageContent): @@ -404,9 +385,6 @@ class CommandOutputMessage(MessageContent): def message_type(self) -> str: return "user" - def message_title(self) -> Optional[str]: - return "" # Empty title for command output - @dataclass class BashInputMessage(MessageContent): @@ -421,9 +399,6 @@ class BashInputMessage(MessageContent): def message_type(self) -> str: return "bash-input" - def message_title(self) -> Optional[str]: - return "Bash command" - @dataclass class BashOutputMessage(MessageContent): @@ -439,9 +414,6 @@ class BashOutputMessage(MessageContent): def message_type(self) -> str: return "bash-output" - def message_title(self) -> Optional[str]: - return "" # Empty title for bash output - # Note: ToolResultMessage and ToolUseMessage are defined in the # "Tool Message Models" section (before Tool Input Models). @@ -467,9 +439,6 @@ def message_type(self) -> str: def has_markdown(self) -> bool: return True - def message_title(self) -> Optional[str]: - return "User (compacted conversation)" - @dataclass class UserMemoryMessage(MessageContent): @@ -486,9 +455,6 @@ class UserMemoryMessage(MessageContent): def message_type(self) -> str: return "user" - def message_title(self) -> Optional[str]: - return "Memory" - @dataclass class UserSlashCommandMessage(MessageContent): @@ -505,9 +471,6 @@ class UserSlashCommandMessage(MessageContent): def message_type(self) -> str: return "user" - def message_title(self) -> Optional[str]: - return "User (slash command)" - @dataclass class IdeOpenedFile: @@ -579,9 +542,6 @@ class UserTextMessage(MessageContent): def message_type(self) -> str: return "user" - def message_title(self) -> Optional[str]: - return "User" - @dataclass class UserSteeringMessage(UserTextMessage): @@ -591,8 +551,7 @@ class UserSteeringMessage(UserTextMessage): items from the queue. Inherits from UserTextMessage. """ - def message_title(self) -> Optional[str]: - return "User (steering)" + pass # ============================================================================= @@ -635,9 +594,6 @@ def message_type(self) -> str: def has_markdown(self) -> bool: return True - def message_title(self) -> Optional[str]: - return "Assistant" - @dataclass class ThinkingMessage(MessageContent): @@ -664,9 +620,6 @@ def message_type(self) -> str: def has_markdown(self) -> bool: return True - def message_title(self) -> Optional[str]: - return "Thinking" - # Note: ToolUseMessage is also an assistant content type, defined in # "Tool Message Models" section (before Tool Input Models). @@ -686,9 +639,6 @@ class UnknownMessage(MessageContent): def message_type(self) -> str: return "unknown" - def message_title(self) -> Optional[str]: - return "Unknown Content" - # ============================================================================= # Renderer Content Models diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index c319d99b..b7b3bd3d 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -124,26 +124,6 @@ def type(self) -> str: """Get message type from content.""" return self.content.message_type - @property - def message_title(self) -> str: - """Get message title from content or type-based fallback. - - Priority: - 1. Sidechain assistant -> "Sub-assistant" - 2. content.message_title() if not None - 3. Type-based fallback from message_type - - Note: Renderer.title_content() may override this for specific content types. - """ - # Sidechain assistant messages get special title - if self.meta.is_sidechain and isinstance(self.content, AssistantTextMessage): - return "Sub-assistant" - # Try content's message_title method - if title := self.content.message_title(): - return title - # Fallback: convert message_type to title case - return self.content.message_type.replace("_", " ").replace("-", " ").title() - @property def is_session_header(self) -> bool: """Check if this message is a session header.""" @@ -2014,17 +1994,7 @@ class Renderer: """ def _dispatch_format(self, obj: Any) -> str: - """Dispatch to format_{ClassName} method based on object type. - - Walks the object's type MRO to find the most specific format method. - This allows methods for parent classes to serve as fallbacks. - - Args: - obj: Object to format (content, input, or output). - - Returns: - Formatted string (e.g., HTML), or empty string if no handler found. - """ + """Dispatch to format_{ClassName} method based on object type.""" for cls in type(obj).__mro__: if cls is object: break @@ -2032,6 +2002,15 @@ def _dispatch_format(self, obj: Any) -> str: return method(obj) return "" + def _dispatch_title(self, obj: Any, message: "TemplateMessage") -> Optional[str]: + """Dispatch to title_{ClassName} method based on object type.""" + for cls in type(obj).__mro__: + if cls is object: + break + if method := getattr(self, f"title_{cls.__name__}", None): + return method(message) + return None + def format_content(self, message: "TemplateMessage") -> str: """Format message content by dispatching to type-specific method. @@ -2050,7 +2029,7 @@ def title_content(self, message: "TemplateMessage") -> str: """Get message title by dispatching to type-specific title method. Looks for a method named title_{ClassName} (e.g., title_ToolUseMessage). - Falls back to content.message_title() or type-based title. + Falls back to type-based title derived from message_type. Args: message: TemplateMessage to get title for. @@ -2058,14 +2037,88 @@ def title_content(self, message: "TemplateMessage") -> str: Returns: Title string for the message header. """ - # First try title_{ClassName} dispatch + # Try title_{ClassName} dispatch for cls in type(message.content).__mro__: if cls is object: break if method := getattr(self, f"title_{cls.__name__}", None): return method(message) - # Fall back to the message_title property (content-based) - return message.message_title + # Fallback: convert message_type to title case + return message.content.message_type.replace("_", " ").replace("-", " ").title() + + # ------------------------------------------------------------------------- + # Title Methods (return title strings for message headers) + # ------------------------------------------------------------------------- + # These methods return title strings for specific content types. + # Override in subclasses for format-specific titles (e.g., HTML with icons). + + def title_SystemMessage(self, message: "TemplateMessage") -> str: + content = cast("SystemMessage", message.content) + return f"System {content.level.title()}" + + def title_HookSummaryMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "System Hook" + + def title_SlashCommandMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "Slash Command" + + def title_CommandOutputMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "" # Empty title for command output + + def title_BashInputMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "Bash command" + + def title_BashOutputMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "" # Empty title for bash output + + def title_CompactedSummaryMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "User (compacted conversation)" + + def title_UserMemoryMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "Memory" + + def title_UserSlashCommandMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "User (slash command)" + + def title_UserTextMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "User" + + def title_UserSteeringMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "User (steering)" + + def title_AssistantTextMessage(self, message: "TemplateMessage") -> str: + # Sidechain assistant messages get special title + if message.meta.is_sidechain: + return "Sub-assistant" + return "Assistant" + + def title_ThinkingMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "Thinking" + + def title_UnknownMessage(self, message: "TemplateMessage") -> str: # noqa: ARG002 + return "Unknown Content" + + # Tool title methods (dispatch to input/output title methods) + def title_ToolUseMessage(self, message: "TemplateMessage") -> str: + content = cast("ToolUseMessage", message.content) + if title := self._dispatch_title(content.input, message): + return title + return content.tool_name # Default to tool name + + def title_ToolResultMessage(self, message: "TemplateMessage") -> str: + content = cast("ToolResultMessage", message.content) + if content.is_error: + return "Error" + if title := self._dispatch_title(content.output, message): + return title + return "" # Tool results typically don't need a title + + # Tool input title stubs (override in subclasses for custom titles) + # def title_BashInput(self, message: "TemplateMessage") -> str: ... + # def title_ReadInput(self, message: "TemplateMessage") -> str: ... + # def title_EditInput(self, message: "TemplateMessage") -> str: ... + # def title_TaskInput(self, message: "TemplateMessage") -> str: ... + # def title_TodoWriteInput(self, message: "TemplateMessage") -> str: ... # ------------------------------------------------------------------------- # Format Method Stubs (override in subclasses) @@ -2125,12 +2178,6 @@ def format_ToolResultMessage(self, message: "ToolResultMessage") -> str: # def format_ExitPlanModeOutput(self, output: "ExitPlanModeOutput") -> str: ... # def format_ToolResultContent(self, output: "ToolResultContent") -> str: ... # fallback - # ------------------------------------------------------------------------- - # Title Method Stubs (override in subclasses for custom titles) - # ------------------------------------------------------------------------- - # def title_ToolUseMessage(self, message: "TemplateMessage") -> str: ... - # def title_ToolResultMessage(self, message: "TemplateMessage") -> str: ... - # ------------------------------------------------------------------------- # Rendering Entry Points # ------------------------------------------------------------------------- diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 32301472..4d3a204c 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -4986,7 +4986,7 @@
- 🧰 Tool Result +
2025-07-03 15:58:07 @@ -5062,7 +5062,7 @@
- 🛠️ Bash Run the decorator example to show output + 💻 Bash Run the decorator example to show output
2025-07-03 16:04:07 @@ -5079,7 +5079,7 @@
- 🧰 Tool Result +
2025-07-03 16:06:07 @@ -9846,7 +9846,7 @@
- User +
2025-06-14 11:02:20 @@ -14812,7 +14812,7 @@
- 🧰 Tool Result +
2025-07-03 15:58:07 @@ -14888,7 +14888,7 @@
- 🛠️ Bash Run the decorator example to show output + 💻 Bash Run the decorator example to show output
2025-07-03 16:04:07 @@ -14905,7 +14905,7 @@
- 🧰 Tool Result +
2025-07-03 16:06:07 @@ -19612,7 +19612,7 @@
- 🧰 Tool Result +
2025-07-03 15:58:07 @@ -19688,7 +19688,7 @@
- 🛠️ Bash Run the decorator example to show output + 💻 Bash Run the decorator example to show output
2025-07-03 16:04:07 @@ -19705,7 +19705,7 @@
- 🧰 Tool Result +
2025-07-03 16:06:07 diff --git a/test/test_template_data.py b/test/test_template_data.py index 7a8dd606..764dc01b 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -7,6 +7,7 @@ from claude_code_log.converter import load_transcript, load_directory_transcripts from claude_code_log.html.renderer import generate_html, generate_projects_index_html from claude_code_log.renderer import ( + Renderer, TemplateMessage, TemplateProject, TemplateSummary, @@ -33,31 +34,33 @@ def test_template_message_creation(self): ) content = UserTextMessage(meta=meta) msg = TemplateMessage(content, meta) + renderer = Renderer() assert msg.type == "user" assert msg.meta.timestamp == "2025-06-14T10:00:00Z" - assert msg.message_title == "User" + assert renderer.title_content(msg) == "User" - def test_template_message_title_capitalization(self): - """Test that message_title properly capitalizes message types.""" + def test_template_message_title_generation(self): + """Test that Renderer.title_content generates correct titles.""" meta = MessageMeta.empty() + renderer = Renderer() # Test UserTextMessage user_content = UserTextMessage(meta=meta) user_msg = TemplateMessage(user_content, meta) - assert user_msg.message_title == "User" + assert renderer.title_content(user_msg) == "User" # Test AssistantTextMessage assistant_content = AssistantTextMessage(meta=meta) assistant_msg = TemplateMessage(assistant_content, meta) - assert assistant_msg.message_title == "Assistant" + assert renderer.title_content(assistant_msg) == "Assistant" - # Test SessionHeaderMessage (for session type) + # Test SessionHeaderMessage - fallback to type-based title session_content = SessionHeaderMessage( meta=meta, title="Test Session", session_id="test-id" ) session_msg = TemplateMessage(session_content, meta) - assert session_msg.message_title == "Session Header" + assert renderer.title_content(session_msg) == "Session Header" class TestTemplateProject: From 3149dd4078685e1c139ddd8f29a481af7507e7cb Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 26 Dec 2025 00:22:30 +0100 Subject: [PATCH 48/57] Remove meta parameter from TemplateMessage constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemplateMessage now derives meta from content.meta instead of taking it as a separate parameter. This simplifies the API since the meta was always identical to content.meta at all call sites. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 23 ++++++++--------------- test/test_template_data.py | 10 +++++----- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index b7b3bd3d..4b2f7406 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -27,7 +27,6 @@ ThinkingContent, UsageInfo, # Structured content types - AssistantTextMessage, CommandOutputMessage, DedupNoticeMessage, SessionHeaderMessage, @@ -84,21 +83,20 @@ class TemplateMessage: def __init__( self, content: "MessageContent", - meta: "MessageMeta", *, # Force keyword arguments after this message_id: Optional[str] = None, ancestry: Optional[list[str]] = None, uuid: Optional[str] = None, ): - # Required: content and meta + # Content carries its own meta self.content = content - self.meta = meta + self.meta = content.meta # Rendering metadata self.message_id = message_id self.ancestry = ancestry or [] - # uuid can differ from meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") - self.uuid = uuid if uuid is not None else meta.uuid + # uuid can differ from content.meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") + self.uuid = uuid if uuid is not None else self.meta.uuid # Fold/unfold counts self.immediate_children_count = 0 # Direct children only @@ -1700,9 +1698,7 @@ def _render_messages( if isinstance(message, SystemTranscriptEntry): system_content = create_system_message(message) if system_content: - template_messages.append( - TemplateMessage(system_content, system_content.meta) - ) + template_messages.append(TemplateMessage(system_content)) continue # Skip summary messages (should be filtered in pass 1, but be defensive) @@ -1768,10 +1764,7 @@ def _render_messages( session_id=session_id, summary=current_session_summary, ) - session_header = TemplateMessage( - session_header_content, - session_header_meta, - ) + session_header = TemplateMessage(session_header_content) template_messages.append(session_header) # Extract token usage for assistant messages @@ -1824,7 +1817,7 @@ def _render_messages( if not chunk or content_model is None: continue - template_messages.append(TemplateMessage(content_model, meta)) + template_messages.append(TemplateMessage(content_model)) else: # Special chunk: single tool_use/tool_result/thinking item @@ -1870,7 +1863,7 @@ def _render_messages( continue template_messages.append( - TemplateMessage(tool_result.content, meta, uuid=tool_uuid) + TemplateMessage(tool_result.content, uuid=tool_uuid) ) return template_messages diff --git a/test/test_template_data.py b/test/test_template_data.py index 764dc01b..dcde77f0 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -33,7 +33,7 @@ def test_template_message_creation(self): uuid="test-uuid", ) content = UserTextMessage(meta=meta) - msg = TemplateMessage(content, meta) + msg = TemplateMessage(content) renderer = Renderer() assert msg.type == "user" @@ -47,19 +47,19 @@ def test_template_message_title_generation(self): # Test UserTextMessage user_content = UserTextMessage(meta=meta) - user_msg = TemplateMessage(user_content, meta) + user_msg = TemplateMessage(user_content) assert renderer.title_content(user_msg) == "User" # Test AssistantTextMessage assistant_content = AssistantTextMessage(meta=meta) - assistant_msg = TemplateMessage(assistant_content, meta) + assistant_msg = TemplateMessage(assistant_content) assert renderer.title_content(assistant_msg) == "Assistant" # Test SessionHeaderMessage - fallback to type-based title session_content = SessionHeaderMessage( meta=meta, title="Test Session", session_id="test-id" ) - session_msg = TemplateMessage(session_content, meta) + session_msg = TemplateMessage(session_content) assert renderer.title_content(session_msg) == "Session Header" @@ -423,7 +423,7 @@ def _create_message( # Fallback to UserTextMessage for unknown types content = UserTextMessage(meta=meta) - msg = TemplateMessage(content, meta, message_id=msg_id, ancestry=ancestry) + msg = TemplateMessage(content, message_id=msg_id, ancestry=ancestry) return msg def test_children_field_default_empty(self): From 66a4fb69896094f5d703661525d919b575a2be88 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 26 Dec 2025 00:27:13 +0100 Subject: [PATCH 49/57] Update MESSAGE_REFACTORING2.md with completed goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 refactoring is essentially complete: - Inverted relationship: MessageContent.meta is source of truth - Leaner TemplateMessage: has_markdown/raw_text_content on content classes - Title dispatch: Renderer.title_content() with title_{ClassName} methods - Models split remains optional for future organization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- dev-docs/MESSAGE_REFACTORING2.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/dev-docs/MESSAGE_REFACTORING2.md b/dev-docs/MESSAGE_REFACTORING2.md index 62238907..43f92fc4 100644 --- a/dev-docs/MESSAGE_REFACTORING2.md +++ b/dev-docs/MESSAGE_REFACTORING2.md @@ -17,9 +17,12 @@ The goal is to achieve a cleaner, type-driven architecture where: - **Removed `ContentBlock`** from `ContentItem` union - using our own types - **Simplified `_process_regular_message`** - content type detection drives rendering - **CSS_CLASS_REGISTRY** derives CSS classes from content types (in `html/utils.py`) -- **MessageModifiers removed** - only `is_sidechain` remains as a flag on `TemplateMessage` +- **MessageModifiers removed** - only `is_sidechain` remains as a flag on `MessageMeta` - **UserSteeringMessage** created for queue-operation "remove" messages - **IdeNotificationContent** is now a plain dataclass (not a MessageContent subclass) +- **Inverted relationship achieved** - `MessageContent.meta` is the source of truth, `TemplateMessage.meta = content.meta` +- **Leaner TemplateMessage** - `has_markdown` delegates to content, `raw_text_content` moved to content classes +- **Title dispatch pattern** - `Renderer.title_content()` dispatches to `title_{ClassName}` methods ### Factory Organization ✓ @@ -50,13 +53,14 @@ def create_thinking_message(meta: MessageMeta, tool_item: ContentItem) -> ... This ensures every `MessageContent` subclass has valid metadata. -### Remaining Goals +### Goals Status | Goal | Status | Notes | |------|--------|-------| -| Inverted relationship | ❌ Pending | Still `TemplateMessage.content: MessageContent`, not `MessageContent.meta` | -| Leaner TemplateMessage | ❌ Pending | Still has `has_markdown`, `raw_text_content` | -| Models split | ❌ Pending | Still single `models.py` | +| Inverted relationship | ✅ Done | `MessageContent.meta` is source of truth, `TemplateMessage.meta = content.meta` | +| Leaner TemplateMessage | ✅ Done | `has_markdown` delegates to content, `raw_text_content` on content classes | +| Title dispatch | ✅ Done | `Renderer.title_content()` with `title_{ClassName}` methods | +| Models split | ❌ Optional | Still single `models.py` - could split if needed | ## Cache Considerations @@ -86,6 +90,4 @@ If we decide to split models.py: - `assistant_models.py` - Assistant message content types - `tools_models.py` - Tool use/result models -## Related Work - -See [REMOVE_ANTHROPIC_TYPES.md](REMOVE_ANTHROPIC_TYPES.md) for simplifying Anthropic SDK dependencies. +This is optional and primarily a code organization improvement. From aa0b5555fd5e03fa509c4293442231b7b400a5ea Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 26 Dec 2025 22:51:44 +0100 Subject: [PATCH 50/57] Eliminate TemplateMessage.uuid and refactor pairing indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit improves code clarity by eliminating the confusing `uuid` property from TemplateMessage (which conflated tool_use_id with meta.uuid) and refactoring message pairing to use stable message references. Key changes: - Remove TemplateMessage.uuid property; use meta.uuid explicitly - Store TemplateMessage references directly in PairingIndices (not int positions) - Refactor _build_pairing_indices to iterate without enumerate - Simplify _try_pair_by_index (remove messages parameter) - Use message_id tracking in _reorder_paired_messages - Remove redundant target_uuid from DedupNoticeMessage (use target_message_id) - Delete _resolve_dedup_targets function (now set target_message_id directly) - Rename _current_msg_uuid to _current_msg_id for consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/html/renderer.py | 4 +- claude_code_log/models.py | 28 +- claude_code_log/renderer.py | 287 ++++++++++++--------- claude_code_log/renderer_timings.py | 6 +- test/__snapshots__/test_snapshot_html.ambr | 148 +++++------ test/test_template_data.py | 12 +- 6 files changed, 285 insertions(+), 200 deletions(-) diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index ec278bcb..0bb5acde 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -343,8 +343,8 @@ def _flatten_preorder( set_timing_var("_pygments_timings", pygments_timings) def visit(msg: TemplateMessage) -> None: - # Update current message UUID for timing tracking - set_timing_var("_current_msg_uuid", msg.uuid) + # Update current message ID for timing tracking + set_timing_var("_current_msg_id", msg.message_id) title = self.title_content(msg) html = self.format_content(msg) formatted_ts = format_timestamp(msg.meta.timestamp if msg.meta else None) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 271ebed8..89e7eb76 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -258,6 +258,9 @@ class MessageMeta: cwd: str = "" git_branch: Optional[str] = None + # Render-time assigned (None until registered with RenderingContext) + message_id: Optional[int] = None + @classmethod def empty(cls, uuid: str = "") -> "MessageMeta": """Create a placeholder MessageMeta with empty/default values. @@ -286,10 +289,22 @@ class MessageContent: The `meta` field is required and first positional, ensuring all message content always has associated metadata. Use MessageMeta.empty() when full metadata isn't available at creation time. + + Relationship fields (populated during rendering): + - children: Indices of child messages in ctx.messages + - ancestry: Indices of ancestor messages in ctx.messages + - pair_first/pair_last: Indices for paired messages (tool_use <-> tool_result) """ meta: MessageMeta + # Render-time relationship fields (indices into RenderingContext.messages) + # Using kw_only=True to allow subclasses to have required fields after meta + children: list[int] = field(default_factory=list, kw_only=True) + ancestry: list[int] = field(default_factory=list, kw_only=True) + pair_first: Optional[int] = field(default=None, kw_only=True) + pair_last: Optional[int] = field(default=None, kw_only=True) + @property def message_type(self) -> str: """Return the message type identifier for this content. @@ -307,6 +322,16 @@ def has_markdown(self) -> bool: """ return False + @property + def is_paired(self) -> bool: + """Whether this message is part of a pair.""" + return self.pair_first is not None or self.pair_last is not None + + @property + def has_children(self) -> bool: + """Whether this message has any children.""" + return bool(self.children) + @dataclass class SystemMessage(MessageContent): @@ -673,8 +698,7 @@ class DedupNoticeMessage(MessageContent): """ notice_text: str - target_uuid: Optional[str] = None # UUID of target message (for resolving link) - target_message_id: Optional[str] = None # Resolved message ID for anchor link + target_message_id: Optional[str] = None # Message ID for anchor link original_text: Optional[str] = None # Original duplicated content (for debugging) @property diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 4b2f7406..3d4712f3 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -3,16 +3,16 @@ import re import time -from dataclasses import dataclass +from dataclasses import dataclass, field, replace from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Tuple, cast from datetime import datetime if TYPE_CHECKING: from .cache import CacheManager - from .models import MessageContent from .models import ( + MessageContent, MessageMeta, MessageType, TranscriptEntry, @@ -65,6 +65,74 @@ ) +# -- Rendering Context -------------------------------------------------------- + + +@dataclass +class RenderingContext: + """Context for a single rendering operation. + + Holds render-time state that should not pollute MessageContent. + This enables parallel-safe rendering where each render gets its own context. + + Attributes: + messages: Registry of all MessageContent objects (message_id = index). + tool_use_context: Maps tool_use_id -> ToolUseContent for result rendering. + session_first_message: Maps session_id -> index of first message in session. + """ + + messages: list["MessageContent"] = field(default_factory=list) + tool_use_context: dict[str, "ToolUseContent"] = field(default_factory=dict) + session_first_message: dict[str, int] = field(default_factory=dict) + + def register(self, content: MessageContent) -> int: + """Register a MessageContent and assign its message_id. + + Args: + content: The MessageContent to register. + + Returns: + The assigned message_id (= index in messages list). + """ + msg_id = len(self.messages) + content.meta.message_id = msg_id + self.messages.append(content) + return msg_id + + def get(self, message_id: int) -> Optional[MessageContent]: + """Get a MessageContent by its message_id. + + Args: + message_id: The message_id (index) to look up. + + Returns: + The MessageContent if found, None if out of range. + """ + if 0 <= message_id < len(self.messages): + return self.messages[message_id] + return None + + def register_tool_use(self, tool_use_id: str, tool_use: ToolUseContent) -> None: + """Register a tool_use for later lookup when processing its result. + + Args: + tool_use_id: The unique ID of the tool use. + tool_use: The ToolUseContent object. + """ + self.tool_use_context[tool_use_id] = tool_use + + def get_tool_use(self, tool_use_id: str) -> Optional[ToolUseContent]: + """Get a previously registered tool_use by ID. + + Args: + tool_use_id: The unique ID of the tool use. + + Returns: + The ToolUseContent if found, None otherwise. + """ + return self.tool_use_context.get(tool_use_id) + + # -- Template Classes --------------------------------------------------------- @@ -84,19 +152,14 @@ def __init__( self, content: "MessageContent", *, # Force keyword arguments after this - message_id: Optional[str] = None, ancestry: Optional[list[str]] = None, - uuid: Optional[str] = None, ): # Content carries its own meta self.content = content self.meta = content.meta # Rendering metadata - self.message_id = message_id self.ancestry = ancestry or [] - # uuid can differ from content.meta.uuid (e.g., for chunks: "{uuid}-chunk-{idx}") - self.uuid = uuid if uuid is not None else self.meta.uuid # Fold/unfold counts self.immediate_children_count = 0 # Direct children only @@ -137,6 +200,20 @@ def has_children(self) -> bool: """Check if this message has any children.""" return bool(self.children) + @property + def message_id(self) -> Optional[str]: + """Get formatted message ID for HTML element IDs. + + Returns "session-{session_id}" for session headers, + "d-{message_id}" for other messages, or None if not registered. + """ + if self.meta.message_id is None: + return None + # Session headers use special format for navigation links + if self.is_session_header and self.meta.session_id: + return f"session-{self.meta.session_id}" + return f"d-{self.meta.message_id}" + @property def session_id(self) -> str: """Get session_id from meta.""" @@ -498,10 +575,6 @@ def generate_template_messages( with log_timing("Build message hierarchy", t_start): _build_message_hierarchy(template_messages) - # Resolve dedup notice targets (needs message_id from hierarchy) - with log_timing("Resolve dedup targets", t_start): - _resolve_dedup_targets(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) @@ -700,46 +773,48 @@ class PairingIndices: """Indices for efficient message pairing lookups. All indices are built in a single pass for efficiency. + Stores message references directly (not list positions). """ - # (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] + # (session_id, tool_use_id) -> TemplateMessage for tool_use messages + tool_use: dict[tuple[str, str], TemplateMessage] + # (session_id, tool_use_id) -> TemplateMessage for tool_result messages + tool_result: dict[tuple[str, str], TemplateMessage] + # uuid -> TemplateMessage for system messages (parent-child pairing) + uuid: dict[str, TemplateMessage] + # parent_uuid -> TemplateMessage for slash-command messages + slash_command_by_parent: dict[str, TemplateMessage] 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. + Stores message references directly for robust lookup after reordering. """ - 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] = {} + tool_use_index: dict[tuple[str, str], TemplateMessage] = {} + tool_result_index: dict[tuple[str, str], TemplateMessage] = {} + uuid_index: dict[str, TemplateMessage] = {} + slash_command_by_parent: dict[str, TemplateMessage] = {} - for i, msg in enumerate(messages): + for msg in 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 + tool_use_index[key] = msg elif msg.type == "tool_result": - tool_result_index[key] = i + tool_result_index[key] = msg # Index system messages by UUID for parent-child pairing - if msg.uuid and msg.type == "system": - uuid_index[msg.uuid] = i + if msg.meta.uuid and msg.type == "system": + uuid_index[msg.meta.uuid] = msg # Index slash-command user messages by parent_uuid if msg.parent_uuid and isinstance( msg.content, (SlashCommandMessage, UserSlashCommandMessage) ): - slash_command_by_parent[msg.parent_uuid] = i + slash_command_by_parent[msg.parent_uuid] = msg return PairingIndices( tool_use=tool_use_index, @@ -792,7 +867,6 @@ def _try_pair_adjacent( def _try_pair_by_index( current: TemplateMessage, - messages: list[TemplateMessage], indices: PairingIndices, ) -> None: """Try to pair current message with another using index lookups. @@ -806,20 +880,19 @@ def _try_pair_by_index( 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) + _mark_pair(current, indices.tool_result[key]) # 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) + _mark_pair(indices.uuid[current.parent_uuid], 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) + if ( + current.type == "system" + and current.meta.uuid in indices.slash_command_by_parent + ): + _mark_pair(current, indices.slash_command_by_parent[current.meta.uuid]) def _identify_message_pairs(messages: list[TemplateMessage]) -> None: @@ -856,7 +929,7 @@ def _identify_message_pairs(messages: list[TemplateMessage]) -> None: continue # Try index-based pairing (doesn't skip, continues to next message) - _try_pair_by_index(current, messages, indices) + _try_pair_by_index(current, indices) i += 1 @@ -877,13 +950,12 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe # Build index of pair_last messages by (session_id, tool_use_id) # Session ID is included to prevent cross-session pairing when sessions are resumed - pair_last_index: dict[ - tuple[str, str], int - ] = {} # (session_id, tool_use_id) -> message index + # Stores message references directly (not list positions) + pair_last_index: dict[tuple[str, str], TemplateMessage] = {} # Index slash-command pair_last messages by parent_uuid - slash_command_pair_index: dict[str, int] = {} # parent_uuid -> message index + slash_command_pair_index: dict[str, TemplateMessage] = {} - for i, msg in enumerate(messages): + for msg in messages: if ( msg.is_paired and msg.pair_role == "pair_last" @@ -891,7 +963,7 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe and msg.session_id ): key = (msg.session_id, msg.tool_use_id) - pair_last_index[key] = i + pair_last_index[key] = msg # Index slash-command messages by parent_uuid if ( msg.is_paired @@ -899,45 +971,43 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe and msg.parent_uuid and isinstance(msg.content, (SlashCommandMessage, UserSlashCommandMessage)) ): - slash_command_pair_index[msg.parent_uuid] = i + slash_command_pair_index[msg.parent_uuid] = msg # Create reordered list reordered: list[TemplateMessage] = [] - skip_indices: set[int] = set() + already_added: set[int] = set() # Track by message_id (unique per message) - for i, msg in enumerate(messages): - if i in skip_indices: + for msg in messages: + msg_id = msg.meta.message_id + if msg_id in already_added: continue reordered.append(msg) + if msg_id is not None: + already_added.add(msg_id) # If this is the first message in a pair, immediately add its pair_last # Key includes session_id to prevent cross-session pairing on resume if msg.is_paired and msg.pair_role == "pair_first": - pair_last = None - last_idx = None + pair_last: Optional[TemplateMessage] = None # Check for tool_use_id based pairs if msg.tool_use_id and msg.session_id: key = (msg.session_id, msg.tool_use_id) if key in pair_last_index: - last_idx = pair_last_index[key] - pair_last = messages[last_idx] + pair_last = pair_last_index[key] # Check for system + slash-command pairs (via uuid -> parent_uuid) - if pair_last is None and msg.uuid and msg.uuid in slash_command_pair_index: - last_idx = slash_command_pair_index[msg.uuid] - pair_last = messages[last_idx] + if pair_last is None and msg.meta.uuid in slash_command_pair_index: + pair_last = slash_command_pair_index[msg.meta.uuid] # 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) + if pair_last is not None: + pair_last_id = pair_last.meta.message_id + if pair_last_id is not None and pair_last_id not in already_added: + reordered.append(pair_last) + already_added.add(pair_last_id) # Calculate duration between pair messages try: @@ -1035,7 +1105,7 @@ def _get_message_hierarchy_level(msg: TemplateMessage) -> int: def _build_message_hierarchy(messages: list[TemplateMessage]) -> None: - """Build message_id and ancestry for all messages based on their current order. + """Build ancestry for all messages based on their current order. This should be called after all reordering operations (pair reordering, sidechain reordering) to ensure the hierarchy reflects the final display order. @@ -1043,11 +1113,13 @@ def _build_message_hierarchy(messages: list[TemplateMessage]) -> None: The hierarchy is determined by message type using _get_message_hierarchy_level(), and a stack-based approach builds proper parent-child relationships. + Note: message_id is now a property derived from content.meta.message_id, + which is assigned during ctx.register() in _render_messages. + Args: messages: List of template messages in their final order (modified in place) """ hierarchy_stack: list[tuple[int, str]] = [] - message_id_counter = 0 for message in messages: # Session headers are level 0 @@ -1064,19 +1136,11 @@ def _build_message_hierarchy(messages: list[TemplateMessage]) -> None: # Build ancestry from remaining stack ancestry = [msg_id for _, msg_id in hierarchy_stack] - # Generate new message ID - # Session headers use session-{session_id} format for navigation links - if message.is_session_header and message.session_id: - message_id = f"session-{message.session_id}" - else: - message_id = f"d-{message_id_counter}" - message_id_counter += 1 - - # Push current message onto stack - hierarchy_stack.append((current_level, message_id)) + # Push current message onto stack (message_id is now a property) + if message.message_id: + hierarchy_stack.append((current_level, message.message_id)) - # Update the message - message.message_id = message_id + # Update the message ancestry message.ancestry = ancestry @@ -1300,7 +1364,6 @@ def process_message(message: TemplateMessage) -> None: child.content = DedupNoticeMessage( MessageMeta.empty(), notice_text="Task summary — see result above", - target_uuid=message.uuid, target_message_id=message.message_id, original_text=child_text, ) @@ -1451,23 +1514,6 @@ def _reorder_sidechain_template_messages( return result -def _resolve_dedup_targets(messages: list[TemplateMessage]) -> None: - """Resolve dedup notice target UUIDs to message IDs for anchor links. - - Must be called after _build_message_hierarchy assigns message_id values. - """ - # Build uuid -> message_id mapping - uuid_to_id: dict[str, str] = {} - for msg in messages: - if msg.uuid and msg.message_id: - uuid_to_id[msg.uuid] = msg.message_id - - # Resolve dedup notice targets - for msg in messages: - if isinstance(msg.content, DedupNoticeMessage) and msg.content.target_uuid: - msg.content.target_message_id = uuid_to_id.get(msg.content.target_uuid) - - def _filter_messages(messages: list[TranscriptEntry]) -> list[TranscriptEntry]: """Filter messages to those that should be rendered. @@ -1682,12 +1728,12 @@ def _render_messages( Returns: List of TemplateMessage objects ready for template rendering """ + # Create rendering context for this operation + ctx = RenderingContext() + # Track which sessions have had headers added seen_sessions: set[str] = set() - # Build mapping of tool_use_id to ToolUseContent for specialized tool result rendering - tool_use_context: dict[str, ToolUseContent] = {} - # Process messages into template-friendly format template_messages: list[TemplateMessage] = [] @@ -1698,6 +1744,7 @@ def _render_messages( if isinstance(message, SystemTranscriptEntry): system_content = create_system_message(message) if system_content: + ctx.register(system_content) template_messages.append(TemplateMessage(system_content)) continue @@ -1764,6 +1811,9 @@ def _render_messages( session_id=session_id, summary=current_session_summary, ) + # Register and track session's first message + msg_id = ctx.register(session_header_content) + ctx.session_first_message[session_id] = msg_id session_header = TemplateMessage(session_header_content) template_messages.append(session_header) @@ -1782,6 +1832,10 @@ def _render_messages( # Process each chunk - regular chunks (list) become text/image messages, # special chunks (single item) become tool/thinking messages for chunk in chunks: + # Each chunk needs its own meta copy so ctx.register() can set + # unique message_id for each without overwriting previous chunks + chunk_meta = replace(meta) + # Regular chunk: list of text/image items if isinstance(chunk, list): # Extract text for pattern detection @@ -1792,16 +1846,18 @@ def _render_messages( # (user message parsing handles all type detection internally) if effective_type == "user": content_model = create_user_message( - meta, + chunk_meta, chunk, # Pass the chunk items chunk_text, # Pre-extracted text for pattern detection - is_slash_command=meta.is_meta, + is_slash_command=chunk_meta.is_meta, ) elif effective_type == "assistant": # Pass usage only on first chunk chunk_usage = usage_to_show if not usage_used else None usage_used = True - content_model = create_assistant_message(meta, chunk, chunk_usage) + content_model = create_assistant_message( + chunk_meta, chunk, chunk_usage + ) # Convert to UserSteeringMessage for queue-operation 'remove' messages if ( @@ -1810,13 +1866,14 @@ def _render_messages( and isinstance(content_model, UserTextMessage) ): content_model = UserSteeringMessage( - items=content_model.items, meta=meta + items=content_model.items, meta=chunk_meta ) # Skip empty chunks or when no content model was created if not chunk or content_model is None: continue + ctx.register(content_model) template_messages.append(TemplateMessage(content_model)) else: @@ -1827,17 +1884,19 @@ def _render_messages( tool_result: ToolItemResult if isinstance(tool_item, ToolUseContent): tool_result = create_tool_use_message( - meta, tool_item, tool_use_context + chunk_meta, tool_item, ctx.tool_use_context ) elif isinstance(tool_item, ToolResultContent): tool_result = create_tool_result_message( - meta, tool_item, tool_use_context + chunk_meta, tool_item, ctx.tool_use_context ) elif isinstance(tool_item, ThinkingContent): # Pass usage only if not yet used chunk_usage = usage_to_show if not usage_used else None usage_used = True - content = create_thinking_message(meta, tool_item, chunk_usage) + content = create_thinking_message( + chunk_meta, tool_item, chunk_usage + ) tool_result = ToolItemResult( message_type=content.message_type, content=content, @@ -1846,25 +1905,17 @@ def _render_messages( # Handle unknown content types tool_result = ToolItemResult( message_type="unknown", - content=UnknownMessage(meta, type_name=str(type(tool_item))), + content=UnknownMessage( + chunk_meta, type_name=str(type(tool_item)) + ), ) - # Generate unique UUID for this tool message - # Use tool_use_id if available, otherwise fall back to msg UUID + index - message_uuid = meta.uuid or "no-uuid" - tool_uuid = ( - tool_result.tool_use_id - if tool_result.tool_use_id - else f"{message_uuid}-tool-{len(template_messages)}" - ) - # Skip if no content (shouldn't happen, but be safe) if tool_result.content is None: continue - template_messages.append( - TemplateMessage(tool_result.content, uuid=tool_uuid) - ) + ctx.register(tool_result.content) + template_messages.append(TemplateMessage(tool_result.content)) return template_messages diff --git a/claude_code_log/renderer_timings.py b/claude_code_log/renderer_timings.py index d4a6e994..e004f71d 100644 --- a/claude_code_log/renderer_timings.py +++ b/claude_code_log/renderer_timings.py @@ -25,7 +25,7 @@ def set_timing_var(name: str, value: Any) -> None: """Set a timing variable in the global timing data dict. Args: - name: Variable name (e.g., "_markdown_timings", "_pygments_timings", "_current_msg_uuid") + name: Variable name (e.g., "_markdown_timings", "_pygments_timings", "_current_msg_id") value: Value to set """ if DEBUG_TIMING: @@ -106,8 +106,8 @@ def timing_stat(list_name: str) -> Iterator[None]: finally: duration = time.time() - t_start if list_name in _timing_data: - msg_uuid = _timing_data.get("_current_msg_uuid", "") - _timing_data[list_name].append((duration, msg_uuid)) + msg_id = _timing_data.get("_current_msg_id", "") + _timing_data[list_name].append((duration, msg_id)) def report_timing_statistics( diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 4d3a204c..e0d58a2d 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -4874,7 +4874,7 @@ -
+
🤷 User
@@ -4886,10 +4886,10 @@
Hello Claude! Can you help me understand how Python decorators work?
-
+
-
+
1 assistant
@@ -4901,7 +4901,7 @@ -
+
🤖 Assistant
@@ -4940,7 +4940,7 @@ -
+
🤷 User
@@ -4952,10 +4952,10 @@
Great! Can you also show me how to create a decorator that takes parameters?
-
+
-
+
1 tool, 1 assistant
@@ -4967,7 +4967,7 @@ -
+
📝 Edit /tmp/decorator_example.py
@@ -4984,7 +4984,7 @@ -
+
@@ -5001,7 +5001,7 @@ -
+
🤖 Assistant
@@ -5033,7 +5033,7 @@ -
+
🤷 User
@@ -5045,10 +5045,10 @@
Can you run that example to show the output?
-
+
-
+
1 tool, 1 assistant
@@ -5060,7 +5060,7 @@ -
+
💻 Bash Run the decorator example to show output
@@ -5077,7 +5077,7 @@ -
+
@@ -5096,7 +5096,7 @@ -
+
🤖 Assistant
@@ -5124,7 +5124,7 @@ -
+
🤷 User
@@ -9674,7 +9674,7 @@ -
+
🤷 User
@@ -9686,10 +9686,10 @@
Here's a message with some **markdown** formatting, `inline code`, and even a [link](https://example.com). Let's see how it renders!
-
+
-
+
1 assistant
@@ -9701,7 +9701,7 @@ -
+
🤖 Assistant
@@ -9759,7 +9759,7 @@ -
+
🤷 User
@@ -9771,10 +9771,10 @@
Let's test a very long message to see how it handles text wrapping and layout. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.
-
+
-
+
1 tool
@@ -9786,7 +9786,7 @@ -
+
🛠️ FailingTool
@@ -9808,7 +9808,7 @@ -
+
🚨 Error
@@ -9825,7 +9825,7 @@ -
+
🤷 Slash Command
@@ -9844,7 +9844,7 @@ -
+
@@ -9861,14 +9861,14 @@ Status: SUCCESS Timestamp: 2025-06-14T11:02:20Z
-
+
-
+
1 assistant
-
+
▼▼ 1 assistant, 1 tool total
@@ -9880,7 +9880,7 @@ -
+
🤖 Assistant
@@ -9895,10 +9895,10 @@

I see the long Lorem ipsum text wraps nicely! Long text handling is important for readability. The CSS should handle word wrapping automatically.

-
+
-
+
1 tool
@@ -9910,7 +9910,7 @@ -
+
🛠️ MultiEdit
@@ -9927,7 +9927,7 @@ -
+
🤷 User
@@ -9944,7 +9944,7 @@ -
+
🤷 User
@@ -9979,7 +9979,7 @@ -
+
📝 Todo List
@@ -14614,7 +14614,7 @@ -
+
🤷 User
@@ -14626,10 +14626,10 @@
This is from a different session file to test multi-session handling.
-
+
-
+
1 assistant
@@ -14641,7 +14641,7 @@ -
+
🤖 Assistant
@@ -14661,7 +14661,7 @@ -
+
🤷 User
@@ -14727,7 +14727,7 @@ -
+
🤖 Assistant
@@ -14766,7 +14766,7 @@ -
+
🤷 User
@@ -14778,10 +14778,10 @@
Great! Can you also show me how to create a decorator that takes parameters?
-
+
-
+
1 tool, 1 assistant
@@ -14793,7 +14793,7 @@ -
+
📝 Edit /tmp/decorator_example.py
@@ -14810,7 +14810,7 @@ -
+
@@ -14827,7 +14827,7 @@ -
+
🤖 Assistant
@@ -14859,7 +14859,7 @@ -
+
🤷 User
@@ -14871,10 +14871,10 @@
Can you run that example to show the output?
-
+
-
+
1 tool, 1 assistant
@@ -14886,7 +14886,7 @@ -
+
💻 Bash Run the decorator example to show output
@@ -14903,7 +14903,7 @@ -
+
@@ -14922,7 +14922,7 @@ -
+
🤖 Assistant
@@ -14950,7 +14950,7 @@ -
+
🤷 User
@@ -19500,7 +19500,7 @@ -
+
🤷 User
@@ -19512,10 +19512,10 @@
Hello Claude! Can you help me understand how Python decorators work?
-
+
-
+
1 assistant
@@ -19527,7 +19527,7 @@ -
+
🤖 Assistant
@@ -19566,7 +19566,7 @@ -
+
🤷 User
@@ -19578,10 +19578,10 @@
Great! Can you also show me how to create a decorator that takes parameters?
-
+
-
+
1 tool, 1 assistant
@@ -19593,7 +19593,7 @@ -
+
📝 Edit /tmp/decorator_example.py
@@ -19610,7 +19610,7 @@ -
+
@@ -19627,7 +19627,7 @@ -
+
🤖 Assistant
@@ -19659,7 +19659,7 @@ -
+
🤷 User
@@ -19671,10 +19671,10 @@
Can you run that example to show the output?
-
+
-
+
1 tool, 1 assistant
@@ -19686,7 +19686,7 @@ -
+
💻 Bash Run the decorator example to show output
@@ -19703,7 +19703,7 @@ -
+
@@ -19722,7 +19722,7 @@ -
+
🤖 Assistant
@@ -19750,7 +19750,7 @@ -
+
🤷 User
diff --git a/test/test_template_data.py b/test/test_template_data.py index dcde77f0..7631c780 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -388,6 +388,8 @@ def test_malformed_message_handling(self): class TestTemplateMessageTree: """Test TemplateMessage tree building.""" + _message_counter = 0 + def _create_message( self, msg_type: str, @@ -395,10 +397,18 @@ def _create_message( ancestry: list[str] | None = None, ) -> TemplateMessage: """Helper to create a minimal TemplateMessage for testing.""" + # Parse int message_id from string if provided (e.g., "d-0" -> 0) + if msg_id and msg_id.startswith("d-"): + int_msg_id = int(msg_id[2:]) + else: + int_msg_id = TestTemplateMessageTree._message_counter + TestTemplateMessageTree._message_counter += 1 + meta = MessageMeta( session_id="test-session", timestamp="2025-06-14T10:00:00Z", uuid=msg_id or "test-uuid", + message_id=int_msg_id, ) # Create appropriate content based on message type @@ -423,7 +433,7 @@ def _create_message( # Fallback to UserTextMessage for unknown types content = UserTextMessage(meta=meta) - msg = TemplateMessage(content, message_id=msg_id, ancestry=ancestry) + msg = TemplateMessage(content, ancestry=ancestry) return msg def test_children_field_default_empty(self): From e541fce5128b4fd3506986b2889298d6795e9b0d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 27 Dec 2025 02:03:13 +0100 Subject: [PATCH 51/57] Establish TemplateMessage as primary render-time object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move pairing fields (pair_first, pair_last, pair_duration) from MessageContent to TemplateMessage - Add message_index field to TemplateMessage (assigned by ctx.register()) - Update RenderingContext to register TemplateMessage instead of MessageContent - Remove MessageMeta.message_id (replaced by TemplateMessage.message_index) - Remove relationship fields from MessageContent (children, ancestry, etc.) - Remove unused register_tool_use/get_tool_use from RenderingContext - Update _mark_pair and _reorder_paired_messages to use message_index - Update MESSAGE_REFACTORING2.md with new architecture documentation This establishes a clean separation: MessageContent holds pure transcript data, while TemplateMessage holds all render-time state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/models.py | 26 +---- claude_code_log/renderer.py | 178 +++++++++++++++++-------------- dev-docs/MESSAGE_REFACTORING2.md | 28 +++++ test/test_template_data.py | 8 +- 4 files changed, 130 insertions(+), 110 deletions(-) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 89e7eb76..220e1352 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -258,9 +258,6 @@ class MessageMeta: cwd: str = "" git_branch: Optional[str] = None - # Render-time assigned (None until registered with RenderingContext) - message_id: Optional[int] = None - @classmethod def empty(cls, uuid: str = "") -> "MessageMeta": """Create a placeholder MessageMeta with empty/default values. @@ -290,21 +287,12 @@ class MessageContent: content always has associated metadata. Use MessageMeta.empty() when full metadata isn't available at creation time. - Relationship fields (populated during rendering): - - children: Indices of child messages in ctx.messages - - ancestry: Indices of ancestor messages in ctx.messages - - pair_first/pair_last: Indices for paired messages (tool_use <-> tool_result) + Note: Render-time relationship data (pairing, hierarchy, children) is stored + on TemplateMessage, not here. MessageContent is pure transcript data. """ meta: MessageMeta - # Render-time relationship fields (indices into RenderingContext.messages) - # Using kw_only=True to allow subclasses to have required fields after meta - children: list[int] = field(default_factory=list, kw_only=True) - ancestry: list[int] = field(default_factory=list, kw_only=True) - pair_first: Optional[int] = field(default=None, kw_only=True) - pair_last: Optional[int] = field(default=None, kw_only=True) - @property def message_type(self) -> str: """Return the message type identifier for this content. @@ -322,16 +310,6 @@ def has_markdown(self) -> bool: """ return False - @property - def is_paired(self) -> bool: - """Whether this message is part of a pair.""" - return self.pair_first is not None or self.pair_last is not None - - @property - def has_children(self) -> bool: - """Whether this message has any children.""" - return bool(self.children) - @dataclass class SystemMessage(MessageContent): diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 3d4712f3..37d869c2 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -76,62 +76,42 @@ class RenderingContext: This enables parallel-safe rendering where each render gets its own context. Attributes: - messages: Registry of all MessageContent objects (message_id = index). + messages: Registry of all TemplateMessage objects (message_index = index). tool_use_context: Maps tool_use_id -> ToolUseContent for result rendering. session_first_message: Maps session_id -> index of first message in session. """ - messages: list["MessageContent"] = field(default_factory=list) + messages: list["TemplateMessage"] = field(default_factory=list) tool_use_context: dict[str, "ToolUseContent"] = field(default_factory=dict) session_first_message: dict[str, int] = field(default_factory=dict) - def register(self, content: MessageContent) -> int: - """Register a MessageContent and assign its message_id. + def register(self, message: "TemplateMessage") -> int: + """Register a TemplateMessage and assign its message_index. Args: - content: The MessageContent to register. + message: The TemplateMessage to register. Returns: - The assigned message_id (= index in messages list). + The assigned message_index (= index in messages list). """ - msg_id = len(self.messages) - content.meta.message_id = msg_id - self.messages.append(content) - return msg_id + msg_index = len(self.messages) + message.message_index = msg_index + self.messages.append(message) + return msg_index - def get(self, message_id: int) -> Optional[MessageContent]: - """Get a MessageContent by its message_id. + def get(self, message_index: int) -> Optional["TemplateMessage"]: + """Get a TemplateMessage by its message_index. Args: - message_id: The message_id (index) to look up. + message_index: The message_index (index) to look up. Returns: - The MessageContent if found, None if out of range. + The TemplateMessage if found, None if out of range. """ - if 0 <= message_id < len(self.messages): - return self.messages[message_id] + if 0 <= message_index < len(self.messages): + return self.messages[message_index] return None - def register_tool_use(self, tool_use_id: str, tool_use: ToolUseContent) -> None: - """Register a tool_use for later lookup when processing its result. - - Args: - tool_use_id: The unique ID of the tool use. - tool_use: The ToolUseContent object. - """ - self.tool_use_context[tool_use_id] = tool_use - - def get_tool_use(self, tool_use_id: str) -> Optional[ToolUseContent]: - """Get a previously registered tool_use by ID. - - Args: - tool_use_id: The unique ID of the tool use. - - Returns: - The ToolUseContent if found, None otherwise. - """ - return self.tool_use_context.get(tool_use_id) - # -- Template Classes --------------------------------------------------------- @@ -139,10 +119,14 @@ def get_tool_use(self, tool_use_id: str) -> Optional[ToolUseContent]: class TemplateMessage: """Structured message data for template rendering. - This is a lightweight wrapper around MessageContent that adds: - - Rendering metadata (message_id, ancestry) - - Tree structure (children, fold/unfold counts) - - Pairing metadata (is_paired, pair_role, pair_duration) + This is the primary render-time object that wraps MessageContent. Each + MessageContent has exactly one TemplateMessage wrapper. + + TemplateMessage holds all render-time state: + - message_index: Index in RenderingContext.messages (unique identifier) + - Pairing metadata: pair_first, pair_last, pair_duration + - Hierarchy metadata: ancestry + - Tree structure: children, fold/unfold counts All identity/context fields come from meta (timestamp, session_id, etc.) and content (tool_use_id, has_markdown, token_usage, etc.). @@ -158,6 +142,14 @@ def __init__( self.content = content self.meta = content.meta + # Unique index in RenderingContext.messages (assigned by ctx.register()) + self.message_index: Optional[int] = None + + # Pairing metadata (assigned by _mark_pair()) + self.pair_first: Optional[int] = None # Index of first message in pair + self.pair_last: Optional[int] = None # Index of last message in pair + self.pair_duration: Optional[str] = None # Duration string for pair_last + # Rendering metadata self.ancestry = ancestry or [] @@ -170,11 +162,6 @@ def __init__( ] = {} # {"assistant": 2, "tool_use": 3} self.total_descendants_by_type: dict[str, int] = {} # All descendants by type - # Pairing metadata - 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 self.children: list["TemplateMessage"] = [] @@ -200,19 +187,49 @@ def has_children(self) -> bool: """Check if this message has any children.""" return bool(self.children) + @property + def is_paired(self) -> bool: + """Check if this message is part of a pair.""" + return self.pair_first is not None or self.pair_last is not None + + @property + def is_first_in_pair(self) -> bool: + """Check if this is the first message in a pair (has pair_last set).""" + return self.pair_last is not None + + @property + def is_last_in_pair(self) -> bool: + """Check if this is the last message in a pair (has pair_first set).""" + return self.pair_first is not None + + @property + def pair_role(self) -> Optional[str]: + """Get the pairing role for CSS class. + + Returns: + "pair_first" if this is the first message in a pair, + "pair_last" if this is the last message in a pair, + None if not paired. + """ + if self.is_first_in_pair: + return "pair_first" + if self.is_last_in_pair: + return "pair_last" + return None + @property def message_id(self) -> Optional[str]: """Get formatted message ID for HTML element IDs. Returns "session-{session_id}" for session headers, - "d-{message_id}" for other messages, or None if not registered. + "d-{message_index}" for other messages, or None if not registered. """ - if self.meta.message_id is None: + if self.message_index is None: return None # Session headers use special format for navigation links if self.is_session_header and self.meta.session_id: return f"session-{self.meta.session_id}" - return f"d-{self.meta.message_id}" + return f"d-{self.message_index}" @property def session_id(self) -> str: @@ -825,11 +842,12 @@ def _build_pairing_indices(messages: list[TemplateMessage]) -> PairingIndices: 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" + """Mark two messages as a pair by setting their pair indices.""" + first_index = first.message_index + last_index = last.message_index + if first_index is not None and last_index is not None: + first.pair_last = last_index + last.pair_first = first_index def _try_pair_adjacent( @@ -956,18 +974,12 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe slash_command_pair_index: dict[str, TemplateMessage] = {} for msg in messages: - if ( - msg.is_paired - and msg.pair_role == "pair_last" - and msg.tool_use_id - and msg.session_id - ): + if msg.is_last_in_pair and msg.tool_use_id and msg.session_id: key = (msg.session_id, msg.tool_use_id) pair_last_index[key] = msg # Index slash-command messages by parent_uuid if ( - msg.is_paired - and msg.pair_role == "pair_last" + msg.is_last_in_pair and msg.parent_uuid and isinstance(msg.content, (SlashCommandMessage, UserSlashCommandMessage)) ): @@ -975,20 +987,20 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe # Create reordered list reordered: list[TemplateMessage] = [] - already_added: set[int] = set() # Track by message_id (unique per message) + already_added: set[int] = set() # Track by message_index (unique per message) for msg in messages: - msg_id = msg.meta.message_id - if msg_id in already_added: + msg_index = msg.message_index + if msg_index in already_added: continue reordered.append(msg) - if msg_id is not None: - already_added.add(msg_id) + if msg_index is not None: + already_added.add(msg_index) # If this is the first message in a pair, immediately add its pair_last # Key includes session_id to prevent cross-session pairing on resume - if msg.is_paired and msg.pair_role == "pair_first": + if msg.is_first_in_pair: pair_last: Optional[TemplateMessage] = None # Check for tool_use_id based pairs @@ -1004,10 +1016,10 @@ def _reorder_paired_messages(messages: list[TemplateMessage]) -> list[TemplateMe # 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: - pair_last_id = pair_last.meta.message_id - if pair_last_id is not None and pair_last_id not in already_added: + last_msg_index = pair_last.message_index + if last_msg_index is not None and last_msg_index not in already_added: reordered.append(pair_last) - already_added.add(pair_last_id) + already_added.add(last_msg_index) # Calculate duration between pair messages try: @@ -1113,7 +1125,7 @@ def _build_message_hierarchy(messages: list[TemplateMessage]) -> None: The hierarchy is determined by message type using _get_message_hierarchy_level(), and a stack-based approach builds proper parent-child relationships. - Note: message_id is now a property derived from content.meta.message_id, + Note: message_id is a property derived from message_index, which is assigned during ctx.register() in _render_messages. Args: @@ -1169,7 +1181,7 @@ def _mark_messages_with_children(messages: list[TemplateMessage]) -> None: # Skip counting pair_last messages (second in a pair) # Pairs are visually presented as a single unit, so we only count the first - if message.is_paired and message.pair_role == "pair_last": + if message.is_last_in_pair: continue # Get immediate parent (last in ancestry list) @@ -1744,8 +1756,9 @@ def _render_messages( if isinstance(message, SystemTranscriptEntry): system_content = create_system_message(message) if system_content: - ctx.register(system_content) - template_messages.append(TemplateMessage(system_content)) + system_msg = TemplateMessage(system_content) + ctx.register(system_msg) + template_messages.append(system_msg) continue # Skip summary messages (should be filtered in pass 1, but be defensive) @@ -1812,9 +1825,9 @@ def _render_messages( summary=current_session_summary, ) # Register and track session's first message - msg_id = ctx.register(session_header_content) - ctx.session_first_message[session_id] = msg_id session_header = TemplateMessage(session_header_content) + msg_index = ctx.register(session_header) + ctx.session_first_message[session_id] = msg_index template_messages.append(session_header) # Extract token usage for assistant messages @@ -1832,8 +1845,7 @@ def _render_messages( # Process each chunk - regular chunks (list) become text/image messages, # special chunks (single item) become tool/thinking messages for chunk in chunks: - # Each chunk needs its own meta copy so ctx.register() can set - # unique message_id for each without overwriting previous chunks + # Each chunk needs its own meta copy to preserve original values chunk_meta = replace(meta) # Regular chunk: list of text/image items @@ -1873,8 +1885,9 @@ def _render_messages( if not chunk or content_model is None: continue - ctx.register(content_model) - template_messages.append(TemplateMessage(content_model)) + chunk_msg = TemplateMessage(content_model) + ctx.register(chunk_msg) + template_messages.append(chunk_msg) else: # Special chunk: single tool_use/tool_result/thinking item @@ -1914,8 +1927,9 @@ def _render_messages( if tool_result.content is None: continue - ctx.register(tool_result.content) - template_messages.append(TemplateMessage(tool_result.content)) + tool_msg = TemplateMessage(tool_result.content) + ctx.register(tool_msg) + template_messages.append(tool_msg) return template_messages diff --git a/dev-docs/MESSAGE_REFACTORING2.md b/dev-docs/MESSAGE_REFACTORING2.md index 43f92fc4..fc84aaec 100644 --- a/dev-docs/MESSAGE_REFACTORING2.md +++ b/dev-docs/MESSAGE_REFACTORING2.md @@ -60,8 +60,36 @@ This ensures every `MessageContent` subclass has valid metadata. | Inverted relationship | ✅ Done | `MessageContent.meta` is source of truth, `TemplateMessage.meta = content.meta` | | Leaner TemplateMessage | ✅ Done | `has_markdown` delegates to content, `raw_text_content` on content classes | | Title dispatch | ✅ Done | `Renderer.title_content()` with `title_{ClassName}` methods | +| Pure MessageContent | ✅ Done | MessageContent has no render-time fields (relationship data on TemplateMessage) | +| TemplateMessage as primary | ✅ Done | RenderingContext registers TemplateMessage, holds pairing/hierarchy data | | Models split | ❌ Optional | Still single `models.py` - could split if needed | +### TemplateMessage Architecture ✓ + +TemplateMessage is now the primary render-time object, with clear separation of concerns: + +**MessageContent** (pure transcript data): +- `meta: MessageMeta` - metadata from transcript +- `message_type` property - type identifier +- `has_markdown` property - whether content has markdown + +**TemplateMessage** (render-time wrapper): +- `content: MessageContent` - the wrapped content +- `meta = content.meta` - convenience alias +- `message_index: Optional[int]` - index in RenderingContext.messages +- `message_id` property - formatted ID for HTML ("d-{index}" or "session-{id}") +- Pairing fields: `pair_first`, `pair_last`, `pair_duration` +- Pairing properties: `is_paired`, `is_first_in_pair`, `is_last_in_pair`, `pair_role` +- Hierarchy fields: `ancestry`, `children` +- Fold/unfold counts: `immediate_children_count`, `total_descendants_count`, etc. + +**RenderingContext**: +- `messages: list[TemplateMessage]` - registry of all messages +- `register(message: TemplateMessage) -> int` - assigns `message_index` +- `get(message_index: int) -> TemplateMessage` - lookup by index +- `tool_use_context: dict[str, ToolUseContent]` - for tool result pairing +- `session_first_message: dict[str, int]` - session header indices + ## Cache Considerations **Good news**: The cache stores `TranscriptEntry` objects (raw parsed data), not `TemplateMessage`: diff --git a/test/test_template_data.py b/test/test_template_data.py index 7631c780..6897da24 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -397,18 +397,17 @@ def _create_message( ancestry: list[str] | None = None, ) -> TemplateMessage: """Helper to create a minimal TemplateMessage for testing.""" - # Parse int message_id from string if provided (e.g., "d-0" -> 0) + # Parse int message_index from string if provided (e.g., "d-0" -> 0) if msg_id and msg_id.startswith("d-"): - int_msg_id = int(msg_id[2:]) + int_msg_index = int(msg_id[2:]) else: - int_msg_id = TestTemplateMessageTree._message_counter + int_msg_index = TestTemplateMessageTree._message_counter TestTemplateMessageTree._message_counter += 1 meta = MessageMeta( session_id="test-session", timestamp="2025-06-14T10:00:00Z", uuid=msg_id or "test-uuid", - message_id=int_msg_id, ) # Create appropriate content based on message type @@ -434,6 +433,7 @@ def _create_message( content = UserTextMessage(meta=meta) msg = TemplateMessage(content, ancestry=ancestry) + msg.message_index = int_msg_index # Set message_index on TemplateMessage return msg def test_children_field_default_empty(self): From b189b1150584916bceb84924da083517d4649884 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 27 Dec 2025 02:28:35 +0100 Subject: [PATCH 52/57] Simplify rendering: return ctx, unify message_id, store int ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return RenderingContext from _render_messages instead of template_messages - Update callers to use ctx.messages (identical to previous template_messages) - Unify session header message_id to use d-{index} like all other messages - Add data-session-id attribute to preserve session lookup for search - Store integer indices in ancestry instead of string message_ids - Update _mark_messages_with_children and _build_message_tree for int lookup - Template now prefixes ancestry with "d-" for CSS classes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../html/templates/components/search.html | 2 +- .../templates/components/session_nav.html | 2 +- .../html/templates/transcript.html | 12 +- claude_code_log/renderer.py | 96 ++++----- test/__snapshots__/test_snapshot_html.ambr | 188 +++++++++--------- 5 files changed, 146 insertions(+), 154 deletions(-) diff --git a/claude_code_log/html/templates/components/search.html b/claude_code_log/html/templates/components/search.html index cc36d353..634e2006 100644 --- a/claude_code_log/html/templates/components/search.html +++ b/claude_code_log/html/templates/components/search.html @@ -158,7 +158,7 @@ let prev = messageElement.previousElementSibling; while (prev) { if (prev.classList.contains('session-header')) { - return prev.id.replace('session-', ''); + return prev.dataset.sessionId || null; } prev = prev.previousElementSibling; } diff --git a/claude_code_log/html/templates/components/session_nav.html b/claude_code_log/html/templates/components/session_nav.html index 99568db2..143d4a89 100644 --- a/claude_code_log/html/templates/components/session_nav.html +++ b/claude_code_log/html/templates/components/session_nav.html @@ -12,7 +12,7 @@

Session Navigation

{% for session in sessions %} -