From 2a3082bbfe8f84dcb89ed12d9f11e4c785295c73 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 15:34:32 +0100 Subject: [PATCH 01/35] Add Pygments syntax highlighting for Write tool - Implement format_write_tool_content() with syntax highlighting - Use get_lexer_for_filename() to auto-detect language from file path - Show line numbers in table format via HtmlFormatter - Make collapsible for files > 12 lines with preview of first 5 lines - Add pygments_styles.css with token styling and Write tool CSS - Include new CSS file in transcript.html template --- claude_code_log/renderer.py | 81 +++++++++ .../templates/components/pygments_styles.css | 171 ++++++++++++++++++ claude_code_log/templates/transcript.html | 1 + pyproject.toml | 1 + uv.lock | 2 + 5 files changed, 256 insertions(+) create mode 100644 claude_code_log/templates/components/pygments_styles.css diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 93ef105b..0e93b48b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -11,6 +11,10 @@ import html import mistune from jinja2 import Environment, FileSystemLoader, select_autoescape +from pygments import highlight +from pygments.lexers import get_lexer_for_filename, TextLexer +from pygments.formatters import HtmlFormatter +from pygments.util import ClassNotFound from .models import ( AssistantTranscriptEntry, @@ -290,6 +294,79 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str: """ +def _highlight_code_with_pygments( + code: str, file_path: str, show_linenos: bool = True +) -> str: + """Highlight code using Pygments with appropriate lexer based on file path. + + Args: + code: The source code to highlight + file_path: Path to determine the appropriate lexer + show_linenos: Whether to show line numbers (default: True) + + Returns: + HTML string with syntax-highlighted code + """ + try: + # Try to get lexer based on filename + lexer = get_lexer_for_filename(file_path, code) + except ClassNotFound: + # Fall back to plain text lexer + lexer = TextLexer() + + # Create formatter with line numbers in table format + formatter = HtmlFormatter( + linenos="table" if show_linenos else False, + cssclass="pygments-highlight", + wrapcode=True, + ) + + # Highlight the code + return str(highlight(code, lexer, formatter)) + + +def format_write_tool_content(tool_use: ToolUseContent) -> str: + """Format Write tool use content with Pygments syntax highlighting.""" + file_path = tool_use.input.get("file_path", "") + content = tool_use.input.get("content", "") + + escaped_path = escape_html(file_path) + + html_parts = ["
"] + + # File path header with write icon + html_parts.append(f"
✍️ {escaped_path}
") + + # Highlight code with Pygments + highlighted_html = _highlight_code_with_pygments(content, file_path) + + # Make collapsible if content has more than 12 lines + lines = content.split("\n") + if len(lines) > 12: + # Get preview (first ~5 lines) + preview_lines = lines[:5] + preview_html = _highlight_code_with_pygments( + "\n".join(preview_lines), file_path + ) + + html_parts.append(f""" +
+ + {len(lines)} lines (click to expand) +
{preview_html}
+
+
{highlighted_html}
+
+ """) + else: + # Show directly without collapsible + html_parts.append(f"
{highlighted_html}
") + + html_parts.append("
") + + return "".join(html_parts) + + def format_bash_tool_content(tool_use: ToolUseContent) -> str: """Format Bash tool use content in VS Code extension style.""" command = tool_use.input.get("command", "") @@ -540,6 +617,10 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: if tool_use.name == "Edit": return format_edit_tool_content(tool_use) + # Special handling for Write + if tool_use.name == "Write": + return format_write_tool_content(tool_use) + # Default: render as key/value table using shared renderer return render_params_table(tool_use.input) diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css new file mode 100644 index 00000000..afc23d4c --- /dev/null +++ b/claude_code_log/templates/components/pygments_styles.css @@ -0,0 +1,171 @@ +/* Pygments syntax highlighting styles */ + +/* Base styles for highlighted code blocks */ +.pygments-highlight { + background: var(--color-bg-secondary); + border-radius: 4px; + overflow-x: auto; +} + +.pygments-highlight pre { + margin: 0; + padding: 0.5em; + line-height: 1.4; + font-family: var(--font-monospace); +} + +/* Line numbers table styling */ +.pygments-highlight .highlighttable { + width: 100%; + border-spacing: 0; + background: transparent; +} + +.pygments-highlight .highlighttable td { + padding: 0; + vertical-align: top; +} + +.pygments-highlight .highlighttable td.linenos { + width: 1%; + text-align: right; + user-select: none; + padding-right: 0.8em; + border-right: 1px solid var(--color-border-dim); +} + +.pygments-highlight .linenos pre { + color: var(--color-text-dim); + background: var(--color-bg-tertiary); + padding: 0.5em 0.5em 0.5em 0; + margin: 0; +} + +.pygments-highlight .linenos .normal { + color: inherit; +} + +.pygments-highlight td.code { + width: 99%; +} + +.pygments-highlight td.code pre { + padding-left: 0.8em; +} + +/* Write tool specific styles */ +.write-tool-content { + margin: 0.5em 0; +} + +.write-file-path { + font-weight: 600; + color: var(--color-green); + margin-bottom: 0.5em; + font-family: var(--font-monospace); + font-size: 0.9em; +} + +.write-tool-content .collapsible-code { + margin-top: 0.5em; +} + +.write-tool-content .collapsible-code summary { + cursor: pointer; + padding: 0.5em; + background: var(--color-bg-secondary); + border-radius: 4px; + margin-bottom: 0.5em; +} + +.write-tool-content .collapsible-code summary:hover { + background: var(--color-bg-tertiary); +} + +.write-tool-content .code-preview-label { + font-weight: 600; + color: var(--color-text-secondary); + display: block; + margin-bottom: 0.5em; +} + +.write-tool-content .code-preview { + margin-top: 0.5em; + opacity: 0.7; +} + +.write-tool-content .code-full { + margin-top: 0.5em; +} + +/* Pygments token styles (based on 'default' theme) */ +.highlight { background: var(--color-bg-secondary); } +.highlight .hll { background-color: #ffffcc } +.highlight .c { color: #3D7B7B; font-style: italic } /* Comment */ +.highlight .err { border: 1px solid #FF0000 } /* Error */ +.highlight .k { color: #008000; font-weight: bold } /* Keyword */ +.highlight .o { color: #666666 } /* Operator */ +.highlight .ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.highlight .cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.highlight .cp { color: #9C6500 } /* Comment.Preproc */ +.highlight .cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.highlight .c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.highlight .cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.highlight .gd { color: #A00000 } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +.highlight .gr { color: #E40000 } /* Generic.Error */ +.highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.highlight .gi { color: #008400 } /* Generic.Inserted */ +.highlight .go { color: #717171 } /* Generic.Output */ +.highlight .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.highlight .gt { color: #0044DD } /* Generic.Traceback */ +.highlight .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.highlight .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.highlight .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.highlight .kp { color: #008000 } /* Keyword.Pseudo */ +.highlight .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.highlight .kt { color: #B00040 } /* Keyword.Type */ +.highlight .m { color: #666666 } /* Literal.Number */ +.highlight .s { color: #BA2121 } /* Literal.String */ +.highlight .na { color: #687822 } /* Name.Attribute */ +.highlight .nb { color: #008000 } /* Name.Builtin */ +.highlight .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.highlight .no { color: #880000 } /* Name.Constant */ +.highlight .nd { color: #AA22FF } /* Name.Decorator */ +.highlight .ni { color: #717171; font-weight: bold } /* Name.Entity */ +.highlight .ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.highlight .nf { color: #0000FF } /* Name.Function */ +.highlight .nl { color: #767600 } /* Name.Label */ +.highlight .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.highlight .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.highlight .nv { color: #19177C } /* Name.Variable */ +.highlight .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.highlight .w { color: #bbbbbb } /* Text.Whitespace */ +.highlight .mb { color: #666666 } /* Literal.Number.Bin */ +.highlight .mf { color: #666666 } /* Literal.Number.Float */ +.highlight .mh { color: #666666 } /* Literal.Number.Hex */ +.highlight .mi { color: #666666 } /* Literal.Number.Integer */ +.highlight .mo { color: #666666 } /* Literal.Number.Oct */ +.highlight .sa { color: #BA2121 } /* Literal.String.Affix */ +.highlight .sb { color: #BA2121 } /* Literal.String.Backtick */ +.highlight .sc { color: #BA2121 } /* Literal.String.Char */ +.highlight .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.highlight .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.highlight .s2 { color: #BA2121 } /* Literal.String.Double */ +.highlight .se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.highlight .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.highlight .si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.highlight .sx { color: #008000 } /* Literal.String.Other */ +.highlight .sr { color: #A45A77 } /* Literal.String.Regex */ +.highlight .s1 { color: #BA2121 } /* Literal.String.Single */ +.highlight .ss { color: #19177C } /* Literal.String.Symbol */ +.highlight .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.highlight .fm { color: #0000FF } /* Name.Function.Magic */ +.highlight .vc { color: #19177C } /* Name.Variable.Class */ +.highlight .vg { color: #19177C } /* Name.Variable.Global */ +.highlight .vi { color: #19177C } /* Name.Variable.Instance */ +.highlight .vm { color: #19177C } /* Name.Variable.Magic */ +.highlight .il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 2bd92d33..5eca902f 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -16,6 +16,7 @@ {% include 'components/timeline_styles.css' %} {% include 'components/search_styles.css' %} {% include 'components/edit_diff_styles.css' %} +{% include 'components/pygments_styles.css' %} diff --git a/pyproject.toml b/pyproject.toml index 82c738fb..1af5d8c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "textual>=4.0.0", "packaging>=25.0", "gitpython>=3.1.45", + "pygments>=2.19.1", ] [project.urls] diff --git a/uv.lock b/uv.lock index 2295d9a6..c6186129 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,7 @@ dependencies = [ { name = "mistune" }, { name = "packaging" }, { name = "pydantic" }, + { name = "pygments" }, { name = "textual" }, { name = "toml" }, ] @@ -128,6 +129,7 @@ requires-dist = [ { name = "mistune", specifier = ">=3.1.3" }, { name = "packaging", specifier = ">=25.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pygments", specifier = ">=2.19.1" }, { name = "textual", specifier = ">=4.0.0" }, { name = "toml", specifier = ">=0.10.2" }, ] From fc4b4ea2982af60b59a070fb97bc00f5ceaa8f2e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 15:37:47 +0100 Subject: [PATCH 02/35] Add Pygments syntax highlighting for Read tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement format_read_tool_content() for tool input rendering - Show file icon (πŸ“„) and full file path in tool use message - Implement _parse_read_tool_result() to parse cat-n format output - Extract and render system-reminder tags separately - Use Pygments to highlight code in tool results with line numbers - Build tool_use_context mapping to link tool results to file paths - Add Read tool specific CSS styles - Falls back to generic rendering if parsing fails --- claude_code_log/renderer.py | 128 +++++++++++++++++- .../templates/components/pygments_styles.css | 23 ++++ 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 0e93b48b..d721eedb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -325,6 +325,17 @@ def _highlight_code_with_pygments( return str(highlight(code, lexer, formatter)) +def format_read_tool_content(tool_use: ToolUseContent) -> str: + """Format Read tool use content showing file path.""" + file_path = tool_use.input.get("file_path", "") + + escaped_path = escape_html(file_path) + + # Simple display with read icon and file path + # Don't show offset/limit parameters as they'll be visible in the result + return f"
πŸ“„ {escaped_path}
" + + def format_write_tool_content(tool_use: ToolUseContent) -> str: """Format Write tool use content with Pygments syntax highlighting.""" file_path = tool_use.input.get("file_path", "") @@ -617,6 +628,10 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: if tool_use.name == "Edit": return format_edit_tool_content(tool_use) + # Special handling for Read + if tool_use.name == "Read": + return format_read_tool_content(tool_use) + # Special handling for Write if tool_use.name == "Write": return format_write_tool_content(tool_use) @@ -625,8 +640,62 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: return render_params_table(tool_use.input) -def format_tool_result_content(tool_result: ToolResultContent) -> str: - """Format tool result content as HTML, including images.""" +def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str]]]: + """Parse Read tool result in cat-n format. + + Returns: + Tuple of (code_content, system_reminder) or None if not parseable + """ + import re + + # Check if content matches the cat-n format pattern (line_number β†’ content) + lines = content.split("\n") + if not lines or not re.match(r"\s+\d+β†’", lines[0]): + return None + + # Parse lines + code_lines: List[str] = [] + system_reminder: Optional[str] = None + in_system_reminder = False + + for line in lines: + # 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: + code_lines.append(match.group(1)) + elif line.strip(): # Non-matching non-empty line + # If we encounter a line that doesn't match the format, bail out + return None + + return ("\n".join(code_lines), system_reminder.strip() if system_reminder else None) + + +def format_tool_result_content( + tool_result: ToolResultContent, file_path: Optional[str] = None +) -> str: + """Format tool result content as HTML, including images. + + Args: + tool_result: The tool result content + file_path: Optional file path for context (used for Read tool syntax highlighting) + """ # Handle both string and structured content if isinstance(tool_result.content, str): raw_content = tool_result.content @@ -658,6 +727,28 @@ def format_tool_result_content(tool_result: ToolResultContent) -> str: raw_content = "\n".join(content_parts) has_images = len(image_html_parts) > 0 + # Try to parse as Read tool result if file_path is provided + if file_path and not has_images: + parsed_result = _parse_read_tool_result(raw_content) + if parsed_result: + code_content, system_reminder = parsed_result + + # Highlight code with Pygments + highlighted_html = _highlight_code_with_pygments(code_content, file_path) + + # Build result HTML + result_parts = ["
", highlighted_html] + + # Add system reminder if present + if system_reminder: + escaped_reminder = escape_html(system_reminder) + result_parts.append( + f"
πŸ€– {escaped_reminder}
" + ) + + result_parts.append("
") + return "".join(result_parts) + # Check if this looks like Bash tool output and process ANSI codes # Bash tool results often contain ANSI escape sequences and terminal output if _looks_like_bash_output(raw_content): @@ -1789,6 +1880,26 @@ def generate_html( # Track which messages should show token usage (first occurrence of each requestId) show_tokens_for_message: set[str] = set() + # Build mapping of tool_use_id to tool info for specialized tool result rendering + tool_use_context: Dict[str, Dict[str, Any]] = {} + for message in messages: + if hasattr(message, "message") and hasattr(message.message, "content"): # type: ignore + content = message.message.content # type: ignore + if isinstance(content, list): + for item in content: + # Check if it's a tool_use item + if hasattr(item, "type") and hasattr(item, "id"): + item_type = getattr(item, "type", None) + if item_type == "tool_use": + tool_id = getattr(item, "id", "") + tool_name = getattr(item, "name", "") + tool_input = getattr(item, "input", {}) + if tool_id: + tool_use_context[tool_id] = { + "name": tool_name, + "input": tool_input, + } + # Process messages into template-friendly format template_messages: List[TemplateMessage] = [] @@ -2081,7 +2192,18 @@ def generate_html( else: tool_result_converted = tool_item - tool_content_html = format_tool_result_content(tool_result_converted) + # Get file_path from tool_use context for specialized rendering (e.g., Read tool) + result_file_path: Optional[str] = None + if tool_result_converted.tool_use_id in tool_use_context: + tool_ctx = tool_use_context[tool_result_converted.tool_use_id] + if tool_ctx.get("name") == "Read" and "file_path" in tool_ctx.get( + "input", {} + ): + result_file_path = tool_ctx["input"]["file_path"] + + tool_content_html = format_tool_result_content( + tool_result_converted, result_file_path + ) escaped_id = escape_html(tool_result_converted.tool_use_id) item_tool_use_id = tool_result_converted.tool_use_id tool_title_hint = f"ID: {escaped_id}" diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index afc23d4c..cd248556 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -53,6 +53,29 @@ padding-left: 0.8em; } +/* Read tool specific styles */ +.read-tool-content { + font-weight: 600; + color: var(--color-blue); + margin: 0.5em 0; + font-family: var(--font-monospace); + font-size: 0.9em; +} + +.read-tool-result { + margin: 0.5em 0; +} + +.read-tool-result .system-reminder { + margin-top: 0.5em; + padding: 0.5em; + background: var(--color-bg-secondary); + border-left: 3px solid var(--color-blue); + border-radius: 4px; + font-style: italic; + color: var(--color-text-secondary); +} + /* Write tool specific styles */ .write-tool-content { margin: 0.5em 0; From 4efc00f8255e4c7839e33fdc6fbb8694c67f8a48 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 15:39:53 +0100 Subject: [PATCH 03/35] Add Pygments syntax highlighting for Edit tool results - Implement _parse_edit_tool_result() to extract code snippet from results - Parse cat-n format after skipping preamble text - Use Pygments to highlight extracted code with line numbers - Update tool_use_context matching to include Edit tool - Add Edit tool result CSS styles - Falls back to generic rendering if parsing fails --- claude_code_log/renderer.py | 62 ++++++++++++++++++- .../templates/components/pygments_styles.css | 5 ++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index d721eedb..b5179acb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -687,6 +687,48 @@ def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str]]] return ("\n".join(code_lines), system_reminder.strip() if system_reminder else None) +def _parse_edit_tool_result(content: str) -> Optional[str]: + """Parse Edit tool result to extract code snippet. + + Edit tool results typically have format: + "The file ... has been updated. Here's the result of running `cat -n` on a snippet..." + followed by cat-n formatted lines. + + Returns: + Code content or None if not parseable + """ + import re + + # Look for the cat-n snippet after the preamble + # Pattern: look for first line that matches the cat-n format + lines = content.split("\n") + code_start_idx = None + + for i, line in enumerate(lines): + if re.match(r"\s+\d+β†’", line): + code_start_idx = i + break + + if code_start_idx is None: + return None + + # Parse lines from code_start_idx onwards + code_lines: List[str] = [] + for line in lines[code_start_idx:]: + match = re.match(r"\s+\d+β†’(.*)$", line) + if match: + code_lines.append(match.group(1)) + elif line.strip() == "": # Allow empty lines + continue + else: # Non-matching line, stop parsing + break + + if not code_lines: + return None + + return "\n".join(code_lines) + + def format_tool_result_content( tool_result: ToolResultContent, file_path: Optional[str] = None ) -> str: @@ -749,6 +791,21 @@ def format_tool_result_content( result_parts.append("") return "".join(result_parts) + # Try to parse as Edit tool result if file_path is provided + if file_path and not has_images: + parsed_code = _parse_edit_tool_result(raw_content) + if parsed_code: + # Highlight code with Pygments + highlighted_html = _highlight_code_with_pygments(parsed_code, file_path) + + # Build result HTML + result_parts = [ + "
", + highlighted_html, + "
", + ] + return "".join(result_parts) + # Check if this looks like Bash tool output and process ANSI codes # Bash tool results often contain ANSI escape sequences and terminal output if _looks_like_bash_output(raw_content): @@ -2192,11 +2249,12 @@ def generate_html( else: tool_result_converted = tool_item - # Get file_path from tool_use context for specialized rendering (e.g., Read tool) + # Get file_path from tool_use context for specialized rendering (e.g., Read, Edit tools) result_file_path: Optional[str] = None if tool_result_converted.tool_use_id in tool_use_context: tool_ctx = tool_use_context[tool_result_converted.tool_use_id] - if tool_ctx.get("name") == "Read" and "file_path" in tool_ctx.get( + tool_name = tool_ctx.get("name") + if tool_name in ("Read", "Edit") and "file_path" in tool_ctx.get( "input", {} ): result_file_path = tool_ctx["input"]["file_path"] diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index cd248556..1be87109 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -76,6 +76,11 @@ color: var(--color-text-secondary); } +/* Edit tool result specific styles */ +.edit-tool-result { + margin: 0.5em 0; +} + /* Write tool specific styles */ .write-tool-content { margin: 0.5em 0; From 9229e14623f28cef513ab5d9cd5d0dca9066e392 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 15:42:42 +0100 Subject: [PATCH 04/35] Add specialized rendering for Multiedit tool - Implement format_multiedit_tool_content() for tool input - Refactor diff rendering into reusable _render_single_diff() helper - Show file path, edit count, and individual diffs for each edit - Each edit displayed with numbered header and diff view - Simplify format_edit_tool_content() to use shared helper - Add Multiedit-specific CSS styles with purple color scheme - Tool results use generic rendering (already synthetic) --- claude_code_log/renderer.py | 85 ++++++++++++++----- .../templates/components/pygments_styles.css | 32 +++++++ 2 files changed, 96 insertions(+), 21 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index b5179acb..6d879ebf 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -461,26 +461,12 @@ def render_params_table(params: Dict[str, Any]) -> str: return "".join(html_parts) -def format_edit_tool_content(tool_use: ToolUseContent) -> str: - """Format Edit tool use content as a diff view with intra-line highlighting.""" - import difflib - - file_path = tool_use.input.get("file_path", "") - old_string = tool_use.input.get("old_string", "") - new_string = tool_use.input.get("new_string", "") - replace_all = tool_use.input.get("replace_all", False) +def _render_single_diff(old_string: str, new_string: str) -> str: + """Render a single diff between old_string and new_string. - escaped_path = escape_html(file_path) - - html_parts = ["
"] - - # File path header - html_parts.append(f"
πŸ“ {escaped_path}
") - - if replace_all: - html_parts.append( - "
πŸ”„ Replace all occurrences
" - ) + Returns HTML for the diff view with intra-line highlighting. + """ + import difflib # Split into lines for diff old_lines = old_string.splitlines(keepends=True) @@ -490,7 +476,7 @@ def format_edit_tool_content(tool_use: ToolUseContent) -> str: differ = difflib.Differ() diff: list[str] = list(differ.compare(old_lines, new_lines)) - html_parts.append("
") + html_parts = ["
"] i = 0 while i < len(diff): @@ -569,7 +555,60 @@ def format_edit_tool_content(tool_use: ToolUseContent) -> str: ) i += 1 - html_parts.append("
") + html_parts.append("
") + return "".join(html_parts) + + +def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: + """Format Multiedit tool use content showing multiple diffs.""" + file_path = tool_use.input.get("file_path", "") + edits = tool_use.input.get("edits", []) + + escaped_path = escape_html(file_path) + + html_parts = ["
"] + + # File path header + html_parts.append(f"
πŸ“ {escaped_path}
") + html_parts.append(f"
Applying {len(edits)} edits
") + + # Render each edit as a diff + for idx, edit in enumerate(edits, 1): + old_string = edit.get("old_string", "") + new_string = edit.get("new_string", "") + + html_parts.append( + f"
Edit #{idx}
" + ) + html_parts.append(_render_single_diff(old_string, new_string)) + html_parts.append("
") + + html_parts.append("
") + return "".join(html_parts) + + +def format_edit_tool_content(tool_use: ToolUseContent) -> str: + """Format Edit tool use content as a diff view with intra-line highlighting.""" + file_path = tool_use.input.get("file_path", "") + old_string = tool_use.input.get("old_string", "") + new_string = tool_use.input.get("new_string", "") + replace_all = tool_use.input.get("replace_all", False) + + escaped_path = escape_html(file_path) + + html_parts = ["
"] + + # File path header + html_parts.append(f"
πŸ“ {escaped_path}
") + + if replace_all: + html_parts.append( + "
πŸ”„ Replace all occurrences
" + ) + + # Use shared diff rendering helper + html_parts.append(_render_single_diff(old_string, new_string)) + html_parts.append("
") return "".join(html_parts) @@ -628,6 +667,10 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: if tool_use.name == "Edit": return format_edit_tool_content(tool_use) + # Special handling for Multiedit + if tool_use.name == "Multiedit": + return format_multiedit_tool_content(tool_use) + # Special handling for Read if tool_use.name == "Read": return format_read_tool_content(tool_use) diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 1be87109..68ddb6fa 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -81,6 +81,38 @@ margin: 0.5em 0; } +/* Multiedit tool specific styles */ +.multiedit-tool-content { + margin: 0.5em 0; +} + +.multiedit-file-path { + font-weight: 600; + color: var(--color-purple); + margin-bottom: 0.5em; + font-family: var(--font-monospace); + font-size: 0.9em; +} + +.multiedit-count { + font-size: 0.85em; + color: var(--color-text-secondary); + margin-bottom: 0.8em; +} + +.multiedit-item { + margin-bottom: 1em; + border-left: 2px solid var(--color-border-dim); + padding-left: 1em; +} + +.multiedit-item-header { + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: 0.5em; + font-size: 0.9em; +} + /* Write tool specific styles */ .write-tool-content { margin: 0.5em 0; From e21bcc8e155d9fbbd6be7d1a7300c9bea9b6b6f3 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 16:27:21 +0100 Subject: [PATCH 05/35] Fix Pygments syntax highlighting CSS class and line alignment - Change cssclass from 'pygments-highlight' to 'highlight' to match token styles - Update all CSS selectors from .pygments-highlight to .highlight - Fix line number alignment with pixel-perfect precision: - Set line-height: 1.5 on all pre elements (was 1.4) - Add white-space: pre to line numbers to prevent wrapping - Adjust line number padding to 0.65em 0.5em 0.5em 0.8em - Line numbers now align perfectly with code lines --- claude_code_log/renderer.py | 2 +- .../templates/components/global_styles.css | 10 +++++++ .../templates/components/message_styles.css | 4 --- .../templates/components/pygments_styles.css | 26 ++++++++++--------- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 6d879ebf..cb3fcd5a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -317,7 +317,7 @@ def _highlight_code_with_pygments( # Create formatter with line numbers in table format formatter = HtmlFormatter( linenos="table" if show_linenos else False, - cssclass="pygments-highlight", + cssclass="highlight", wrapcode=True, ) diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index 77442f46..ff8e64b0 100644 --- a/claude_code_log/templates/components/global_styles.css +++ b/claude_code_log/templates/components/global_styles.css @@ -56,6 +56,16 @@ h1 { } /* Common typography */ + +code { + background-color: var(--code-bg-color); + padding: 2px 4px; + border-radius: 3px; + font-family: var(--font-monospace); + line-height: 1.5; +} + + pre { background-color: #12121212; padding: 10px; diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index 8bd2502c..0d57448c 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -339,10 +339,6 @@ pre > code { display: block; } -code { - background-color: var(--code-bg-color); -} - /* Tool content styling */ .tool-content { background-color: var(--neutral-dimmed); diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 68ddb6fa..0f6debc5 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -1,56 +1,58 @@ /* Pygments syntax highlighting styles */ /* Base styles for highlighted code blocks */ -.pygments-highlight { +.highlight { background: var(--color-bg-secondary); border-radius: 4px; overflow-x: auto; } -.pygments-highlight pre { +.highlight pre { margin: 0; padding: 0.5em; - line-height: 1.4; + line-height: 1.5; font-family: var(--font-monospace); } /* Line numbers table styling */ -.pygments-highlight .highlighttable { +.highlight .highlighttable { width: 100%; border-spacing: 0; background: transparent; } -.pygments-highlight .highlighttable td { +.highlight .highlighttable td { padding: 0; vertical-align: top; } -.pygments-highlight .highlighttable td.linenos { +.highlight .highlighttable td.linenos { width: 1%; text-align: right; user-select: none; - padding-right: 0.8em; border-right: 1px solid var(--color-border-dim); } -.pygments-highlight .linenos pre { +.highlight .linenos pre { color: var(--color-text-dim); background: var(--color-bg-tertiary); - padding: 0.5em 0.5em 0.5em 0; + padding: 0.65em 0.5em 0.5em 0.8em; margin: 0; + line-height: 1.5; + white-space: pre; } -.pygments-highlight .linenos .normal { +.highlight .linenos .normal { color: inherit; } -.pygments-highlight td.code { +.highlight td.code { width: 99%; } -.pygments-highlight td.code pre { +.highlight td.code pre { padding-left: 0.8em; + line-height: 1.5; } /* Read tool specific styles */ From e08c467d37ee57e3c77bd17eb25d642762d2aeab Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 16:32:18 +0100 Subject: [PATCH 06/35] Add collapsible sections to Read and Edit tool results - Make Read tool results collapsible when > 12 lines - Make Edit tool results collapsible when > 12 lines - Show preview of first 5 lines in collapsed state - Add CSS styles for collapsible code in both tools - System reminders remain visible outside collapsible section - Matches Write tool collapsible behavior for consistency --- claude_code_log/renderer.py | 56 ++++++++++++++-- .../templates/components/pygments_styles.css | 64 +++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index cb3fcd5a..a9619146 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -822,9 +822,31 @@ def format_tool_result_content( highlighted_html = _highlight_code_with_pygments(code_content, file_path) # Build result HTML - result_parts = ["
", highlighted_html] + result_parts = ["
"] + + # Make collapsible if content has more than 12 lines + lines = code_content.split("\n") + if len(lines) > 12: + # Get preview (first ~5 lines) + preview_lines = lines[:5] + preview_html = _highlight_code_with_pygments( + "\n".join(preview_lines), file_path + ) + + result_parts.append(f""" +
+ + {len(lines)} lines (click to expand) +
{preview_html}
+
+
{highlighted_html}
+
+ """) + else: + # Show directly without collapsible + result_parts.append(highlighted_html) - # Add system reminder if present + # Add system reminder if present (after code, always visible) if system_reminder: escaped_reminder = escape_html(system_reminder) result_parts.append( @@ -842,11 +864,31 @@ def format_tool_result_content( highlighted_html = _highlight_code_with_pygments(parsed_code, file_path) # Build result HTML - result_parts = [ - "
", - highlighted_html, - "
", - ] + result_parts = ["
"] + + # Make collapsible if content has more than 12 lines + lines = parsed_code.split("\n") + if len(lines) > 12: + # Get preview (first ~5 lines) + preview_lines = lines[:5] + preview_html = _highlight_code_with_pygments( + "\n".join(preview_lines), file_path + ) + + result_parts.append(f""" +
+ + {len(lines)} lines (click to expand) +
{preview_html}
+
+
{highlighted_html}
+
+ """) + else: + # Show directly without collapsible + result_parts.append(highlighted_html) + + result_parts.append("
") return "".join(result_parts) # Check if this looks like Bash tool output and process ANSI codes diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 0f6debc5..5e6e01e5 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -68,6 +68,38 @@ margin: 0.5em 0; } +.read-tool-result .collapsible-code { + margin-top: 0.5em; +} + +.read-tool-result .collapsible-code summary { + cursor: pointer; + padding: 0.5em; + background: var(--color-bg-secondary); + border-radius: 4px; + margin-bottom: 0.5em; +} + +.read-tool-result .collapsible-code summary:hover { + background: var(--color-bg-tertiary); +} + +.read-tool-result .code-preview-label { + font-weight: 600; + color: var(--color-text-secondary); + display: block; + margin-bottom: 0.5em; +} + +.read-tool-result .code-preview { + margin-top: 0.5em; + opacity: 0.7; +} + +.read-tool-result .code-full { + margin-top: 0.5em; +} + .read-tool-result .system-reminder { margin-top: 0.5em; padding: 0.5em; @@ -83,6 +115,38 @@ margin: 0.5em 0; } +.edit-tool-result .collapsible-code { + margin-top: 0.5em; +} + +.edit-tool-result .collapsible-code summary { + cursor: pointer; + padding: 0.5em; + background: var(--color-bg-secondary); + border-radius: 4px; + margin-bottom: 0.5em; +} + +.edit-tool-result .collapsible-code summary:hover { + background: var(--color-bg-tertiary); +} + +.edit-tool-result .code-preview-label { + font-weight: 600; + color: var(--color-text-secondary); + display: block; + margin-bottom: 0.5em; +} + +.edit-tool-result .code-preview { + margin-top: 0.5em; + opacity: 0.7; +} + +.edit-tool-result .code-full { + margin-top: 0.5em; +} + /* Multiedit tool specific styles */ .multiedit-tool-content { margin: 0.5em 0; From 585f099373b8868ba5e73fedc336db23ce68a6a0 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 16:39:49 +0100 Subject: [PATCH 07/35] Improve collapsible code preview UX - Hide preview when details are expanded (only show full content) - Add gradient mask to preview (fade to transparent at bottom) - Apply mask-image with 80% black to 100% transparent gradient - Include -webkit-mask-image for browser compatibility - Applied consistently across Write, Read, and Edit tools --- .../templates/components/pygments_styles.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 5e6e01e5..1174654d 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -94,6 +94,12 @@ .read-tool-result .code-preview { margin-top: 0.5em; opacity: 0.7; + mask-image: linear-gradient(to bottom, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); +} + +.read-tool-result .collapsible-code[open] .code-preview { + display: none; } .read-tool-result .code-full { @@ -141,6 +147,12 @@ .edit-tool-result .code-preview { margin-top: 0.5em; opacity: 0.7; + mask-image: linear-gradient(to bottom, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); +} + +.edit-tool-result .collapsible-code[open] .code-preview { + display: none; } .edit-tool-result .code-full { @@ -218,6 +230,12 @@ .write-tool-content .code-preview { margin-top: 0.5em; opacity: 0.7; + mask-image: linear-gradient(to bottom, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); +} + +.write-tool-content .collapsible-code[open] .code-preview { + display: none; } .write-tool-content .code-full { From 03a7d3b645561f4ea8ade72aa5adcdf3656b7065 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 16:46:25 +0100 Subject: [PATCH 08/35] Integrate Pygments with mistune for markdown code block syntax highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When rendering markdown content (assistant messages, thinking blocks, etc.), mistune now uses Pygments to syntax highlight code blocks that have language specifiers (e.g., ```python or ```javascript). Implementation: - Created _create_pygments_plugin() that overrides mistune's block_code renderer - Plugin checks for language info and uses Pygments lexer when available - Falls back to original renderer for code blocks without language hints - Uses HtmlFormatter with linenos=False (no line numbers for inline markdown) - Added plugin to mistune.create_markdown() plugins list This completes the Pygments integration across all content types: - Tool rendering (Write, Read, Edit, Multiedit) - with line numbers - Markdown code blocks - without line numbers πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 39 ++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index a9619146..c417f561 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -179,8 +179,44 @@ def create_collapsible_details( """ +def _create_pygments_plugin() -> Any: + """Create a mistune plugin that uses Pygments for code block syntax highlighting.""" + from pygments import highlight + from pygments.lexers import get_lexer_by_name, TextLexer + from pygments.formatters import HtmlFormatter + from pygments.util import ClassNotFound + + def plugin_pygments(md: Any) -> None: + """Plugin to add Pygments syntax highlighting to code blocks.""" + original_render = md.renderer.block_code + + def block_code(code: str, info: Optional[str] = None) -> str: + """Render code block with Pygments syntax highlighting if language is specified.""" + if info: + # Language hint provided, use Pygments + lang = info.split()[0] if info else "" + try: + lexer = get_lexer_by_name(lang, stripall=True) + except ClassNotFound: + lexer = TextLexer() + + formatter = HtmlFormatter( + linenos=False, # No line numbers in markdown code blocks + cssclass="highlight", + wrapcode=True, + ) + return str(highlight(code, lexer, formatter)) + else: + # No language hint, use default rendering + return original_render(code, info) + + md.renderer.block_code = block_code + + return plugin_pygments + + def render_markdown(text: str) -> str: - """Convert markdown text to HTML using mistune.""" + """Convert markdown text to HTML using mistune with Pygments syntax highlighting.""" # Configure mistune with GitHub-flavored markdown features renderer = mistune.create_markdown( plugins=[ @@ -190,6 +226,7 @@ def render_markdown(text: str) -> str: "url", "task_lists", "def_list", + _create_pygments_plugin(), ], escape=False, # Don't escape HTML since we want to render markdown properly hard_wrap=True, # Line break for newlines (checklists in Assistant messages) From 2ee199c13c32ba5b74c18f386bce4d73fffc871e Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 16:55:05 +0100 Subject: [PATCH 09/35] Fix line number offsets in Read and Edit tool results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When displaying Read and Edit tool results that show partial file content, the line numbers should reflect the actual line numbers from the file, not always start at 1. Changes: - Added linenostart parameter to _highlight_code_with_pygments() - Updated _parse_read_tool_result() to extract and return first line number - Updated _parse_edit_tool_result() to extract and return first line number - Updated all call sites to pass line offset to Pygments HtmlFormatter - Write tool correctly defaults to linenostart=1 (always full file) This ensures that when viewing a snippet from lines 200-210, the line numbers display as 200-210, not 1-11. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 62 +++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index c417f561..f3dccdcb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -332,7 +332,7 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str: def _highlight_code_with_pygments( - code: str, file_path: str, show_linenos: bool = True + code: str, file_path: str, show_linenos: bool = True, linenostart: int = 1 ) -> str: """Highlight code using Pygments with appropriate lexer based on file path. @@ -340,6 +340,7 @@ def _highlight_code_with_pygments( code: The source code to highlight file_path: Path to determine the appropriate lexer show_linenos: Whether to show line numbers (default: True) + linenostart: Starting line number for display (default: 1) Returns: HTML string with syntax-highlighted code @@ -356,6 +357,7 @@ def _highlight_code_with_pygments( linenos="table" if show_linenos else False, cssclass="highlight", wrapcode=True, + linenostart=linenostart, ) # Highlight the code @@ -720,11 +722,11 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: return render_params_table(tool_use.input) -def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str]]]: +def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str], int]]: """Parse Read tool result in cat-n format. Returns: - Tuple of (code_content, system_reminder) or None if not parseable + Tuple of (code_content, system_reminder, line_offset) or None if not parseable """ import re @@ -737,6 +739,7 @@ def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str]]] code_lines: List[str] = [] system_reminder: Optional[str] = None in_system_reminder = False + line_offset = 1 # Default offset for line in lines: # Check for system-reminder start @@ -757,17 +760,25 @@ def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str]]] continue # Parse regular code line (format: " 123β†’content") - match = re.match(r"\s+\d+β†’(.*)$", line) + match = re.match(r"\s+(\d+)β†’(.*)$", line) if match: - code_lines.append(match.group(1)) + 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(): # Non-matching non-empty line # If we encounter a line that doesn't match the format, bail out return None - return ("\n".join(code_lines), system_reminder.strip() if system_reminder else None) + return ( + "\n".join(code_lines), + system_reminder.strip() if system_reminder else None, + line_offset, + ) -def _parse_edit_tool_result(content: str) -> Optional[str]: +def _parse_edit_tool_result(content: str) -> Optional[tuple[str, int]]: """Parse Edit tool result to extract code snippet. Edit tool results typically have format: @@ -775,7 +786,7 @@ def _parse_edit_tool_result(content: str) -> Optional[str]: followed by cat-n formatted lines. Returns: - Code content or None if not parseable + Tuple of (code_content, line_offset) or None if not parseable """ import re @@ -794,10 +805,15 @@ def _parse_edit_tool_result(content: str) -> Optional[str]: # Parse lines from code_start_idx onwards code_lines: List[str] = [] + line_offset = 1 # Default offset for line in lines[code_start_idx:]: - match = re.match(r"\s+\d+β†’(.*)$", line) + match = re.match(r"\s+(\d+)β†’(.*)$", line) if match: - code_lines.append(match.group(1)) + 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 continue else: # Non-matching line, stop parsing @@ -806,7 +822,7 @@ def _parse_edit_tool_result(content: str) -> Optional[str]: if not code_lines: return None - return "\n".join(code_lines) + return ("\n".join(code_lines), line_offset) def format_tool_result_content( @@ -853,10 +869,12 @@ def format_tool_result_content( if file_path and not has_images: parsed_result = _parse_read_tool_result(raw_content) if parsed_result: - code_content, system_reminder = parsed_result + code_content, system_reminder, line_offset = parsed_result - # Highlight code with Pygments - highlighted_html = _highlight_code_with_pygments(code_content, file_path) + # Highlight code with Pygments using correct line offset + highlighted_html = _highlight_code_with_pygments( + code_content, file_path, linenostart=line_offset + ) # Build result HTML result_parts = ["
"] @@ -867,7 +885,7 @@ def format_tool_result_content( # Get preview (first ~5 lines) preview_lines = lines[:5] preview_html = _highlight_code_with_pygments( - "\n".join(preview_lines), file_path + "\n".join(preview_lines), file_path, linenostart=line_offset ) result_parts.append(f""" @@ -895,10 +913,14 @@ def format_tool_result_content( # Try to parse as Edit tool result if file_path is provided if file_path and not has_images: - parsed_code = _parse_edit_tool_result(raw_content) - if parsed_code: - # Highlight code with Pygments - highlighted_html = _highlight_code_with_pygments(parsed_code, file_path) + parsed_result = _parse_edit_tool_result(raw_content) + if parsed_result: + parsed_code, line_offset = parsed_result + + # Highlight code with Pygments using correct line offset + highlighted_html = _highlight_code_with_pygments( + parsed_code, file_path, linenostart=line_offset + ) # Build result HTML result_parts = ["
"] @@ -909,7 +931,7 @@ def format_tool_result_content( # Get preview (first ~5 lines) preview_lines = lines[:5] preview_html = _highlight_code_with_pygments( - "\n".join(preview_lines), file_path + "\n".join(preview_lines), file_path, linenostart=line_offset ) result_parts.append(f""" From 5d0f67f9f57b11fe76c8919e8b91d63106b65c80 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 17:24:32 +0100 Subject: [PATCH 10/35] Add smaller font size for markdown code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces font size to 80% for code blocks within markdown-rendered content (assistant messages, thinking blocks, compacted summaries, sidechain messages) to improve visual hierarchy and readability. Implementation: - Added 'markdown' CSS class conditionally to content divs in template - Applied to message types that render markdown: assistant, thinking, sidechain, compacted - Added CSS rule targeting .content.markdown .highlight pre code - Tool results (Read, Edit, Write, Multiedit) maintain 100% font size with line numbers This ensures consistent styling where: - Tool code displays with full-size font and line numbers - Markdown code blocks display slightly smaller for better text flow πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 2 ++ claude_code_log/templates/components/pygments_styles.css | 5 +++++ claude_code_log/templates/transcript.html | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index f3dccdcb..02915e9a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1323,6 +1323,7 @@ def __init__( token_usage: Optional[str] = None, tool_use_id: Optional[str] = None, title_hint: Optional[str] = None, + has_markdown: bool = False, ): self.type = message_type self.content_html = content_html @@ -1337,6 +1338,7 @@ def __init__( self.token_usage = token_usage self.tool_use_id = tool_use_id self.title_hint = title_hint + self.has_markdown = has_markdown # Pairing metadata self.is_paired = False self.pair_role: Optional[str] = None # "pair_first", "pair_last", "pair_middle" diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 1174654d..c5dfee50 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -14,6 +14,11 @@ font-family: var(--font-monospace); } +/* Smaller font for code blocks in markdown content (assistant messages, thinking, etc.) */ +.content.markdown .highlight pre code { + font-size: 80%; +} + /* Line numbers table styling */ .highlight .highlighttable { width: 100%; diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 5eca902f..63961e00 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -94,7 +94,7 @@

πŸ” Search & Filter

{% endif %}
-
{{ message.content_html | safe }}
+
{{ message.content_html | safe }}
{% endif %} {% endfor %} From 8a3540a5553e40d7a10b16eaaa599270d6c2e58f Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 17:31:31 +0100 Subject: [PATCH 11/35] Refactor template to use set variable for markdown detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves readability and maintainability by extracting the markdown detection logic into a Jinja2 {% set %} variable instead of inline conditional. Changes: - Added {% set markdown = ... %} at start of message block - Simplified content div class to {% if markdown %} markdown{% endif %} - Logic remains identical, just cleaner template structure πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/templates/transcript.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 63961e00..fdaa85ec 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -81,6 +81,7 @@

πŸ” Search & Filter

{% else %} + {% set markdown = message.css_class in ['assistant', 'thinking', 'sidechain'] or (message.css_class and 'compacted' in message.css_class) %}
{% if message.display_type %}{% if message.css_class == 'user' %}🀷 {% elif message.css_class == 'assistant' %}πŸ€– {% elif @@ -94,7 +95,7 @@

πŸ” Search & Filter

{% endif %}
-
{{ message.content_html | safe }}
+
{{ message.content_html | safe }}
{% endif %} {% endfor %} From 922a730361f2feddff76b5da0eba9ae4d1ff74dd Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 17:35:56 +0100 Subject: [PATCH 12/35] Add type: ignore annotations for Pygments imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pygments library doesn't provide type stubs, so pyright reports unknown variable types. Added type: ignore comments to suppress these warnings for Pygments-related imports and usages. Changes: - Added type: ignore[reportUnknownVariableType] to Pygments imports - Added type: ignore[reportUnknownArgumentType] to highlight() calls - Applied to both module-level imports and function-scoped imports in _create_pygments_plugin() All Pygments-related type errors are now suppressed. Remaining 7 errors are pre-existing Anthropic type issues unrelated to Pygments integration. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 02915e9a..3ac546f7 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -11,10 +11,10 @@ import html import mistune from jinja2 import Environment, FileSystemLoader, select_autoescape -from pygments import highlight -from pygments.lexers import get_lexer_for_filename, TextLexer -from pygments.formatters import HtmlFormatter -from pygments.util import ClassNotFound +from pygments import highlight # type: ignore[reportUnknownVariableType] +from pygments.lexers import get_lexer_for_filename, TextLexer # type: ignore[reportUnknownVariableType] +from pygments.formatters import HtmlFormatter # type: ignore[reportUnknownVariableType] +from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType] from .models import ( AssistantTranscriptEntry, @@ -181,10 +181,10 @@ def create_collapsible_details( def _create_pygments_plugin() -> Any: """Create a mistune plugin that uses Pygments for code block syntax highlighting.""" - from pygments import highlight - from pygments.lexers import get_lexer_by_name, TextLexer - from pygments.formatters import HtmlFormatter - from pygments.util import ClassNotFound + from pygments import highlight # type: ignore[reportUnknownVariableType] + from pygments.lexers import get_lexer_by_name, TextLexer # type: ignore[reportUnknownVariableType] + from pygments.formatters import HtmlFormatter # type: ignore[reportUnknownVariableType] + from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType] def plugin_pygments(md: Any) -> None: """Plugin to add Pygments syntax highlighting to code blocks.""" @@ -196,16 +196,16 @@ def block_code(code: str, info: Optional[str] = None) -> str: # Language hint provided, use Pygments lang = info.split()[0] if info else "" try: - lexer = get_lexer_by_name(lang, stripall=True) + lexer = get_lexer_by_name(lang, stripall=True) # type: ignore[reportUnknownVariableType] except ClassNotFound: - lexer = TextLexer() + lexer = TextLexer() # type: ignore[reportUnknownVariableType] - formatter = HtmlFormatter( + formatter = HtmlFormatter( # type: ignore[reportUnknownVariableType] linenos=False, # No line numbers in markdown code blocks cssclass="highlight", wrapcode=True, ) - return str(highlight(code, lexer, formatter)) + return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType] else: # No language hint, use default rendering return original_render(code, info) @@ -347,13 +347,13 @@ def _highlight_code_with_pygments( """ try: # Try to get lexer based on filename - lexer = get_lexer_for_filename(file_path, code) + lexer = get_lexer_for_filename(file_path, code) # type: ignore[reportUnknownVariableType] except ClassNotFound: # Fall back to plain text lexer - lexer = TextLexer() + lexer = TextLexer() # type: ignore[reportUnknownVariableType] # Create formatter with line numbers in table format - formatter = HtmlFormatter( + formatter = HtmlFormatter( # type: ignore[reportUnknownVariableType] linenos="table" if show_linenos else False, cssclass="highlight", wrapcode=True, @@ -361,7 +361,7 @@ def _highlight_code_with_pygments( ) # Highlight the code - return str(highlight(code, lexer, formatter)) + return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType] def format_read_tool_content(tool_use: ToolUseContent) -> str: From d5bf7013bf85ebbe22c63ea3e20cbd5248ca9a91 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 18:27:06 +0100 Subject: [PATCH 13/35] Refine Write tool rendering for better UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved the visual appearance of Write tool with collapsible code sections: 1. Removed write icon (✍️) from file path header to reduce visual clutter when paired with collapsible sections 2. Added left padding (1.5em) to file path to prevent collision with collapse arrow 3. Specialized Write tool result rendering: - On success: show only acknowledgment line with "..." suffix - On error: show full error details - Reduces redundancy since full content is already shown in tool use 4. Added tool_name parameter to format_tool_result_content() for specialized rendering by tool type (Write, Read, Edit) This creates a cleaner, more focused display where the Write tool use shows the full code with collapsible preview, and the result simply confirms success without repeating the entire content. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 39 +++++++++++++------ .../templates/components/pygments_styles.css | 7 ++-- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 3ac546f7..69baf983 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -384,8 +384,8 @@ def format_write_tool_content(tool_use: ToolUseContent) -> str: html_parts = ["
"] - # File path header with write icon - html_parts.append(f"
✍️ {escaped_path}
") + # File path header (no icon to avoid visual clutter with collapsible) + html_parts.append(f"
{escaped_path}
") # Highlight code with Pygments highlighted_html = _highlight_code_with_pygments(content, file_path) @@ -826,13 +826,16 @@ def _parse_edit_tool_result(content: str) -> Optional[tuple[str, int]]: def format_tool_result_content( - tool_result: ToolResultContent, file_path: Optional[str] = None + tool_result: ToolResultContent, + file_path: Optional[str] = None, + tool_name: Optional[str] = None, ) -> str: """Format tool result content as HTML, including images. Args: tool_result: The tool result content - file_path: Optional file path for context (used for Read tool syntax highlighting) + 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") """ # Handle both string and structured content if isinstance(tool_result.content, str): @@ -865,8 +868,17 @@ def format_tool_result_content( raw_content = "\n".join(content_parts) has_images = len(image_html_parts) > 0 + # Special handling for Write tool: only show first line (acknowledgment) on success + if tool_name == "Write" and not tool_result.is_error and not has_images: + lines = raw_content.split("\n") + if lines: + # Keep only the first acknowledgment line and add ellipsis + first_line = lines[0] + escaped_html = escape_html(first_line) + return f"

{escaped_html} ...

" + # Try to parse as Read tool result if file_path is provided - if file_path and not has_images: + if file_path and tool_name == "Read" and not has_images: parsed_result = _parse_read_tool_result(raw_content) if parsed_result: code_content, system_reminder, line_offset = parsed_result @@ -912,7 +924,7 @@ def format_tool_result_content( return "".join(result_parts) # Try to parse as Edit tool result if file_path is provided - if file_path and not has_images: + if file_path and tool_name == "Edit" and not has_images: parsed_result = _parse_edit_tool_result(raw_content) if parsed_result: parsed_code, line_offset = parsed_result @@ -2395,18 +2407,21 @@ def generate_html( else: tool_result_converted = tool_item - # Get file_path from tool_use context for specialized rendering (e.g., Read, Edit tools) + # Get file_path and tool_name from tool_use context for specialized rendering result_file_path: Optional[str] = None + result_tool_name: Optional[str] = None if tool_result_converted.tool_use_id in tool_use_context: tool_ctx = tool_use_context[tool_result_converted.tool_use_id] - tool_name = tool_ctx.get("name") - if tool_name in ("Read", "Edit") and "file_path" in tool_ctx.get( - "input", {} - ): + result_tool_name = tool_ctx.get("name") + if result_tool_name in ( + "Read", + "Edit", + "Write", + ) and "file_path" in tool_ctx.get("input", {}): result_file_path = tool_ctx["input"]["file_path"] tool_content_html = format_tool_result_content( - tool_result_converted, result_file_path + tool_result_converted, result_file_path, result_tool_name ) escaped_id = escape_html(tool_result_converted.tool_use_id) item_tool_use_id = tool_result_converted.tool_use_id diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index c5dfee50..9decb05c 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -74,7 +74,7 @@ } .read-tool-result .collapsible-code { - margin-top: 0.5em; + margin-top: -2.5em; } .read-tool-result .collapsible-code summary { @@ -127,7 +127,7 @@ } .edit-tool-result .collapsible-code { - margin-top: 0.5em; + margin-top: -2.5em; } .edit-tool-result .collapsible-code summary { @@ -207,10 +207,11 @@ margin-bottom: 0.5em; font-family: var(--font-monospace); font-size: 0.9em; + padding-left: 1.5em; } .write-tool-content .collapsible-code { - margin-top: 0.5em; + margin-top: -2.5em; } .write-tool-content .collapsible-code summary { From 79ffe92eec05ce8639d4d3e3dfe4e7271e686c01 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 18:31:48 +0100 Subject: [PATCH 14/35] Fix remaining pyright errors for Anthropic content types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added type: ignore annotations for the tool_use_context building loop where Anthropic's ContentBlock union types include Unknown. These errors were pre-existing but unrelated to Pygments work. The annotations suppress the 7 remaining type errors from hasattr() and getattr() calls on content items that may include Unknown types. Result: 0 pyright errors, 0 warnings πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 69baf983..931dc5a2 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2101,14 +2101,14 @@ def generate_html( if hasattr(message, "message") and hasattr(message.message, "content"): # type: ignore content = message.message.content # type: ignore if isinstance(content, list): - for item in content: + for item in content: # type: ignore[reportUnknownVariableType] # Check if it's a tool_use item - if hasattr(item, "type") and hasattr(item, "id"): - item_type = getattr(item, "type", None) + if hasattr(item, "type") and hasattr(item, "id"): # type: ignore[reportUnknownArgumentType] + item_type = getattr(item, "type", None) # type: ignore[reportUnknownArgumentType] if item_type == "tool_use": - tool_id = getattr(item, "id", "") - tool_name = getattr(item, "name", "") - tool_input = getattr(item, "input", {}) + tool_id = getattr(item, "id", "") # type: ignore[reportUnknownArgumentType] + tool_name = getattr(item, "name", "") # type: ignore[reportUnknownArgumentType] + tool_input = getattr(item, "input", {}) # type: ignore[reportUnknownArgumentType] if tool_id: tool_use_context[tool_id] = { "name": tool_name, From 2e7bf4e737983dbcd5bea0101a17789eb12c56f8 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sun, 2 Nov 2025 21:21:15 +0100 Subject: [PATCH 15/35] Consolidate cat-n parsing and fix Pygments rendering issues - Extracted common cat-n snippet parsing into shared _parse_cat_n_snippet() function used by both _parse_read_tool_result() and _parse_edit_tool_result() - Added white-space: pre to .highlight pre to prevent line wrapping in code blocks, ensuring line numbers stay accurately aligned with code lines - Shared parser allows empty lines between cat-n formatted lines but stops at non-matching non-empty content --- claude_code_log/renderer.py | 67 ++++++++++--------- .../templates/components/pygments_styles.css | 1 + 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 931dc5a2..9dfd3b8d 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -722,26 +722,26 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str: return render_params_table(tool_use.input) -def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str], int]]: - """Parse Read tool result in 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 """ import re - # Check if content matches the cat-n format pattern (line_number β†’ content) - lines = content.split("\n") - if not lines or not re.match(r"\s+\d+β†’", lines[0]): - return None - - # Parse lines code_lines: List[str] = [] system_reminder: Optional[str] = None in_system_reminder = False line_offset = 1 # Default offset - for line in lines: + for line in lines[start_idx:]: # Check for system-reminder start if "" in line: in_system_reminder = True @@ -767,9 +767,13 @@ def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str], if not code_lines: line_offset = line_num code_lines.append(match.group(2)) - elif line.strip(): # Non-matching non-empty line - # If we encounter a line that doesn't match the format, bail out - return None + elif line.strip() == "": # Allow empty lines between cat-n lines + continue + else: # Non-matching non-empty line, stop parsing + break + + if not code_lines: + return None return ( "\n".join(code_lines), @@ -778,6 +782,22 @@ def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str], ) +def _parse_read_tool_result(content: str) -> Optional[tuple[str, Optional[str], int]]: + """Parse Read tool result in cat-n format. + + Returns: + Tuple of (code_content, system_reminder, line_offset) or None if not parseable + """ + import re + + # Check if content matches the cat-n format pattern (line_number β†’ content) + lines = content.split("\n") + if not lines or not re.match(r"\s+\d+β†’", lines[0]): + return None + + return _parse_cat_n_snippet(lines) + + def _parse_edit_tool_result(content: str) -> Optional[tuple[str, int]]: """Parse Edit tool result to extract code snippet. @@ -803,26 +823,13 @@ def _parse_edit_tool_result(content: str) -> Optional[tuple[str, int]]: if code_start_idx is None: return None - # Parse lines from code_start_idx onwards - code_lines: List[str] = [] - line_offset = 1 # Default offset - for line in lines[code_start_idx:]: - 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 - continue - else: # Non-matching line, stop parsing - break - - if not code_lines: + result = _parse_cat_n_snippet(lines, code_start_idx) + if result is None: return None - return ("\n".join(code_lines), line_offset) + code_content, system_reminder, line_offset = result + # Edit tool doesn't use system_reminder, so we just return code and offset + return (code_content, line_offset) def format_tool_result_content( diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 9decb05c..f892b755 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -12,6 +12,7 @@ padding: 0.5em; line-height: 1.5; font-family: var(--font-monospace); + white-space: pre; /* Prevent line wrapping */ } /* Smaller font for code blocks in markdown content (assistant messages, thinking, etc.) */ From 6757266514fba7d529cd463768deb3d1fcb6139c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 5 Nov 2025 22:57:36 +0100 Subject: [PATCH 16/35] Silence pyright. --- claude_code_log/renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 9dfd3b8d..9e43bb44 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -827,7 +827,7 @@ def _parse_edit_tool_result(content: str) -> Optional[tuple[str, int]]: if result is None: return None - code_content, system_reminder, line_offset = result + code_content, _system_reminder, line_offset = result # Edit tool doesn't use system_reminder, so we just return code and offset return (code_content, line_offset) From b081b68d3cccfe13360fc99483103873b16abc74 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 5 Nov 2025 23:13:43 +0100 Subject: [PATCH 17/35] Tweak styles - smaller system-reminder in Read and IDE notification in User - fix bg color for Bash command --- claude_code_log/templates/components/message_styles.css | 6 +++--- claude_code_log/templates/components/pygments_styles.css | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index 0d57448c..ecaea8f3 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -178,8 +178,8 @@ line-height: 1.4; } -.bash-tool-command { - background-color: #f8f9fa; +.content pre.bash-tool-command { + background-color: var(--code-bg-color); padding: 8px 12px; border-radius: 4px; border: 1px solid var(--tool-param-sep-color); @@ -280,7 +280,7 @@ padding: 8px 12px; margin: 8px 0; border-radius: 4px; - font-size: 0.9em; + font-size: 80%; font-style: italic; } diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index f892b755..85003957 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -119,6 +119,7 @@ border-left: 3px solid var(--color-blue); border-radius: 4px; font-style: italic; + font-size: 80%; color: var(--color-text-secondary); } From 22ad87eedf2782965c9057316631bf04f2856035 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 5 Nov 2025 23:19:06 +0100 Subject: [PATCH 18/35] Strip tags from tool result content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove XML wrapper tags from tool error messages while preserving the actual error text. Also strips redundant "String: ..." portions that echo the input parameters. Before: File has not been read yet... After: File has not been read yet... This provides cleaner, more readable error messages in the transcript viewer without unnecessary XML markup. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 9e43bb44..b4f04a6f 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -875,6 +875,21 @@ def format_tool_result_content( raw_content = "\n".join(content_parts) has_images = len(image_html_parts) > 0 + # Strip XML tags but keep the content inside + # Also strip redundant "String: ..." portions that echo the input + import re + + if raw_content: + # Remove ... tags but keep inner content + raw_content = re.sub( + r"(.*?)", + r"\1", + raw_content, + flags=re.DOTALL, + ) + # Remove "String: ..." portions that echo the input (everything after "String:" to end) + raw_content = re.sub(r"\nString:.*$", "", raw_content, flags=re.DOTALL) + # Special handling for Write tool: only show first line (acknowledgment) on success if tool_name == "Write" and not tool_result.is_error and not has_images: lines = raw_content.split("\n") From 9207286070671675aefdbfee764d8b20d2009c73 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 6 Nov 2025 00:26:57 +0100 Subject: [PATCH 19/35] Reorder paired messages to be adjacent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement message reordering so that paired messages (tool_use + tool_result) appear visually adjacent while preserving chronological order for unpaired messages and first messages in pairs. - Add _reorder_paired_messages() function to move pair_last messages immediately after their corresponding pair_first - Maintain chronological ordering for unpaired messages - Calculate and store duration between paired messages for display πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 77 +++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index b4f04a6f..d2844006 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2027,6 +2027,80 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 1 +def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMessage]: + """Reorder messages so paired messages are adjacent while preserving chronological order. + + - Unpaired messages and first messages in pairs maintain chronological order + - Last messages in pairs are moved immediately after their first message + - Timestamps are enhanced to show duration for paired messages + """ + from datetime import datetime + + # Build a map of tool_use_id to pair indices + pair_map: Dict[str, tuple[int, int]] = {} # tool_use_id -> (first_idx, last_idx) + + for i, msg in enumerate(messages): + if msg.is_paired and msg.pair_role == "pair_first" and msg.tool_use_id: + # Find the matching pair_last + for j in range(i + 1, len(messages)): + if ( + messages[j].is_paired + and messages[j].pair_role == "pair_last" + and messages[j].tool_use_id == msg.tool_use_id + ): + pair_map[msg.tool_use_id] = (i, j) + break + + # Create reordered list + reordered: List[TemplateMessage] = [] + skip_indices: set[int] = set() + + for i, msg in enumerate(messages): + if i in skip_indices: + continue + + reordered.append(msg) + + # If this is the first message in a pair, immediately add its pair_last + if msg.is_paired and msg.pair_role == "pair_first" and msg.tool_use_id: + if msg.tool_use_id in pair_map: + first_idx, last_idx = pair_map[msg.tool_use_id] + if first_idx == i: # Confirm this is the right pair + pair_last = messages[last_idx] + reordered.append(pair_last) + skip_indices.add(last_idx) + + # Calculate duration between pair messages + try: + if msg.raw_timestamp and pair_last.raw_timestamp: + # Parse ISO timestamps + first_time = datetime.fromisoformat( + msg.raw_timestamp.replace("Z", "+00:00") + ) + last_time = datetime.fromisoformat( + pair_last.raw_timestamp.replace("Z", "+00:00") + ) + duration = last_time - first_time + + # Format duration nicely + total_seconds = duration.total_seconds() + if total_seconds < 1: + duration_str = f"took {int(total_seconds * 1000)} ms" + elif total_seconds < 60: + duration_str = f"took {total_seconds:.1f}s" + else: + minutes = int(total_seconds // 60) + seconds = int(total_seconds % 60) + duration_str = f"took {minutes}m {seconds}s" + + # Store duration in pair_last for template rendering + pair_last.pair_duration = duration_str + except (ValueError, AttributeError): + pass + + return reordered + + def generate_session_html( messages: List[TranscriptEntry], session_id: str, @@ -2559,6 +2633,9 @@ def generate_html( # Identify and mark paired messages (command+output, tool_use+tool_result, etc.) _identify_message_pairs(template_messages) + # Reorder messages so pairs are adjacent while preserving chronological order + template_messages = _reorder_paired_messages(template_messages) + # Render template env = _get_template_environment() template = env.get_template("transcript.html") From 64f2c1f10b256fe2458d16e343cfa5a40b4e9cde Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 6 Nov 2025 00:28:09 +0100 Subject: [PATCH 20/35] Add duration display for paired messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display the time taken between paired messages (tool_use β†’ tool_result) as a hover tooltip on the timestamp. Changes: - Add pair_duration field to TemplateMessage model - Store duration in data-duration attribute on timestamp span - Update timezone converter to show duration in title for paired messages - Show UTC timestamp in title for unpaired messages only - Add supporting CSS for timestamp layout The duration appears on hover (e.g., "took 2.1s") making it easy to see tool execution time without cluttering the UI. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 1 + .../templates/components/message_styles.css | 26 +++++++++++++++++++ .../components/timezone_converter.js | 7 ++--- claude_code_log/templates/transcript.html | 8 +++--- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index d2844006..a574764a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1376,6 +1376,7 @@ def __init__( # 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 class TemplateProject: diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index ecaea8f3..e3729016 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -12,6 +12,32 @@ border-right: #00000017 1px solid; } +/* Message header info styling */ +.header-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.timestamp-row { + display: flex; + flex-direction: row; + align-items: normal; + gap: 8px; +} + +.pair-duration { + font-size: 80%; + color: #666; + font-style: italic; +} + +.token-usage { + font-size: 0.75em; + color: #888; +} + /* Paired message styling */ .message.paired-message { margin-bottom: 0; diff --git a/claude_code_log/templates/components/timezone_converter.js b/claude_code_log/templates/components/timezone_converter.js index 08bd2fa9..30d4e700 100644 --- a/claude_code_log/templates/components/timezone_converter.js +++ b/claude_code_log/templates/components/timezone_converter.js @@ -46,6 +46,7 @@ const element = timestampElements[i]; const rawTimestamp = element.getAttribute('data-timestamp'); const rawTimestampEnd = element.getAttribute('data-timestamp-end'); + const duration = element.getAttribute('data-duration'); if (!rawTimestamp) continue; @@ -70,7 +71,7 @@ // Update the element with range if (localTime !== utcTime || localTimeEnd !== utcTimeEnd) { element.innerHTML = localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd + ' | Local: ' + localTime + ' to ' + localTimeEnd + ' (' + timezoneName + ')'; + element.title = 'UTC: ' + utcTime + ' to ' + utcTimeEnd; } else { // If they're the same (user is in UTC), just show UTC element.innerHTML = utcTime + ' to ' + utcTimeEnd + ' (UTC)'; @@ -81,11 +82,11 @@ // Single timestamp if (localTime !== utcTime) { element.innerHTML = localTime + ' (' + timezoneName + ')'; - element.title = 'UTC: ' + utcTime + ' | Local: ' + localTime + ' (' + timezoneName + ')'; + element.title = duration ? duration : 'UTC: ' + utcTime; } else { // If they're the same (user is in UTC), just show UTC element.innerHTML = utcTime + ' (UTC)'; - element.title = 'UTC: ' + utcTime; + element.title = duration ? duration : 'UTC: ' + utcTime; } } diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index fdaa85ec..5fc6d34a 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -88,10 +88,12 @@

πŸ” Search & Filter

message.css_class == 'system' %}βš™οΈ {% elif message.css_class == 'tool_use' %}πŸ› οΈ {% elif message.css_class == 'tool_result' %}🧰 {% elif message.css_class == 'thinking' %}πŸ’­ {% elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.display_type }}{% endif %} -
- {{ message.formatted_timestamp }} +
+
+ {{ message.formatted_timestamp }} +
{% if message.token_usage %} - {{ message.token_usage }} + {{ message.token_usage }} {% endif %}
From 5e1641596fecc0b1695d7e87bf781cfde325adf5 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 6 Nov 2025 12:58:45 +0100 Subject: [PATCH 21/35] Move tool summaries to headers for vertical space optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move Bash descriptions from content to header - Move Read/Edit/Write file paths from content to header - Add custom icons: πŸ“ for Edit/Write, πŸ“„ for Read, πŸ“ for TodoWrite - Remove .title() transformation for full control over message types - Add CSS word-breaking for long paths to keep timestamp on right πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 95 ++++++++++++++----- .../templates/components/global_styles.css | 16 ++++ claude_code_log/templates/transcript.html | 11 ++- 3 files changed, 92 insertions(+), 30 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index a574764a..f91abef4 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -364,28 +364,27 @@ def _highlight_code_with_pygments( return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType] -def format_read_tool_content(tool_use: ToolUseContent) -> str: - """Format Read tool use content showing file path.""" - file_path = tool_use.input.get("file_path", "") - - escaped_path = escape_html(file_path) +def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 + """Format Read tool use content showing file path. - # Simple display with read icon and file path + Note: File path is now shown in the header, so we skip content here. + """ + # File path is now shown in header, so no content needed # Don't show offset/limit parameters as they'll be visible in the result - return f"
πŸ“„ {escaped_path}
" + return "" def format_write_tool_content(tool_use: ToolUseContent) -> str: - """Format Write tool use content with Pygments syntax highlighting.""" + """Format Write tool use content with Pygments syntax highlighting. + + Note: File path is now shown in the header, so we skip it here. + """ file_path = tool_use.input.get("file_path", "") content = tool_use.input.get("content", "") - escaped_path = escape_html(file_path) - html_parts = ["
"] - # File path header (no icon to avoid visual clutter with collapsible) - html_parts.append(f"
{escaped_path}
") + # File path is now shown in header, so we skip it here # Highlight code with Pygments highlighted_html = _highlight_code_with_pygments(content, file_path) @@ -418,18 +417,17 @@ def format_write_tool_content(tool_use: ToolUseContent) -> str: def format_bash_tool_content(tool_use: ToolUseContent) -> str: - """Format Bash tool use content in VS Code extension style.""" + """Format Bash tool use content in VS Code extension style. + + Note: Description is now shown in the header, so we skip it here. + """ command = tool_use.input.get("command", "") - description = tool_use.input.get("description", "") escaped_command = escape_html(command) html_parts = ["
"] - # Add description if present - if description: - escaped_desc = escape_html(description) - html_parts.append(f"
{escaped_desc}
") + # Description is now shown in header, so we skip it here # Add command in preformatted block html_parts.append(f"
{escaped_command}
") @@ -627,18 +625,17 @@ def format_multiedit_tool_content(tool_use: ToolUseContent) -> str: def format_edit_tool_content(tool_use: ToolUseContent) -> str: - """Format Edit tool use content as a diff view with intra-line highlighting.""" - file_path = tool_use.input.get("file_path", "") + """Format Edit tool use content as a diff view with intra-line highlighting. + + Note: File path is now shown in the header, so we skip it here. + """ old_string = tool_use.input.get("old_string", "") new_string = tool_use.input.get("new_string", "") replace_all = tool_use.input.get("replace_all", False) - escaped_path = escape_html(file_path) - html_parts = ["
"] - # File path header - html_parts.append(f"
πŸ“ {escaped_path}
") + # File path is now shown in header, so we skip it here if replace_all: html_parts.append( @@ -692,6 +689,29 @@ def _render_line_diff(old_line: str, new_line: str) -> str: return "".join(old_parts) + "".join(new_parts) +def get_tool_summary(tool_use: ToolUseContent) -> Optional[str]: + """Extract a one-line summary from tool parameters for display in header. + + Returns a brief description or filename that can be shown in the message header + to save vertical space. + """ + tool_name = tool_use.name + params = tool_use.input + + if tool_name == "Bash": + # Return description if present + return params.get("description") + + elif tool_name in ("Read", "Edit", "Write"): + # Return file path (without icon - caller adds it) + file_path = params.get("file_path") + if file_path: + return file_path + + # No summary for other tools + return None + + def format_tool_use_content(tool_use: ToolUseContent) -> str: """Format tool use content as HTML.""" # Special handling for TodoWrite @@ -1364,7 +1384,7 @@ def __init__( self.formatted_timestamp = formatted_timestamp self.css_class = css_class self.raw_timestamp = raw_timestamp - self.display_type = message_type.title() + self.display_type = message_type self.session_summary = session_summary self.session_id = session_id self.is_session_header = is_session_header @@ -2486,9 +2506,32 @@ def generate_html( escaped_id = escape_html(tool_use_converted.id) item_tool_use_id = tool_use_converted.id tool_title_hint = f"ID: {escaped_id}" + + # Get summary for header (description or filepath) + summary = get_tool_summary(tool_use_converted) + # Use simplified display names without "Tool Use:" prefix + # Mark tools with custom icons using a prefix if tool_use_converted.name == "TodoWrite": - tool_message_type = "πŸ“ Todo List" + tool_message_type = "__CUSTOM_ICON__πŸ“ Todo List" + elif tool_use_converted.name in ("Edit", "Write"): + # Use πŸ“ icon for Edit/Write - mark with prefix to skip generic icon + if summary: + escaped_summary = escape_html(summary) + tool_message_type = f"__CUSTOM_ICON__πŸ“ {escaped_name} {escaped_summary}" + else: + tool_message_type = f"__CUSTOM_ICON__πŸ“ {escaped_name}" + elif tool_use_converted.name == "Read": + # Use πŸ“„ icon for Read - mark with prefix to skip generic icon + if summary: + escaped_summary = escape_html(summary) + tool_message_type = f"__CUSTOM_ICON__πŸ“„ {escaped_name} {escaped_summary}" + else: + tool_message_type = f"__CUSTOM_ICON__πŸ“„ {escaped_name}" + elif summary: + # For other tools (like Bash), append summary + escaped_summary = escape_html(summary) + tool_message_type = f"{escaped_name} {escaped_summary}" else: tool_message_type = escaped_name tool_css_class = "tool_use" diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index ff8e64b0..5ee38644 100644 --- a/claude_code_log/templates/components/global_styles.css +++ b/claude_code_log/templates/components/global_styles.css @@ -106,6 +106,22 @@ pre { gap: 8px; } +.header > span:first-child { + flex: 1; + min-width: 0; + overflow: hidden; +} + +/* Tool summary in header */ +.tool-summary { + font-weight: normal; + color: var(--text-muted); + font-size: 0.95em; + word-break: break-all; + overflow-wrap: anywhere; + display: inline; +} + /* Timestamps */ .timestamp { font-size: 0.85em; diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 5fc6d34a..5cc8ec09 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -84,10 +84,13 @@

πŸ” Search & Filter

{% set markdown = message.css_class in ['assistant', 'thinking', 'sidechain'] or (message.css_class and 'compacted' in message.css_class) %}
- {% if message.display_type %}{% if message.css_class == 'user' %}🀷 {% elif message.css_class == 'assistant' %}πŸ€– {% elif - message.css_class == 'system' %}βš™οΈ {% elif message.css_class == 'tool_use' %}πŸ› οΈ {% elif - message.css_class == 'tool_result' %}🧰 {% elif message.css_class == 'thinking' %}πŸ’­ {% elif - message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.display_type }}{% endif %} + {% if message.display_type %}{% + if message.css_class == 'user' %}🀷 {% + elif message.css_class == 'assistant' %}πŸ€– {% + elif message.css_class == 'system' %}βš™οΈ {% + elif message.css_class == 'tool_use' and not message.display_type.startswith('__CUSTOM_ICON__') %}πŸ› οΈ {% + elif message.css_class == 'tool_result' %}🧰 {% elif message.css_class == 'thinking' %}πŸ’­ {% + elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.display_type.replace('__CUSTOM_ICON__', '') | safe }}{% endif %}
{{ message.formatted_timestamp }} From e27181d87bcc4e64f7baf3bf030a6118c6eafb14 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 6 Nov 2025 15:19:50 +0100 Subject: [PATCH 22/35] Fix tool_use/tool_result pairing with dictionary-based algorithm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous look-ahead approach had a hardcoded limit of 10 messages, which failed when concurrent tool uses were interleaved. For example, if 5 Read tools were called, then their 5 results arrived, the 1st tool_use would be 11 messages away from its result - beyond the limit. New two-pass algorithm: 1. Build dictionaries mapping tool_use_id to message indices 2. Match pairs using O(1) dictionary lookups instead of O(n) scans This handles any distance between tool_use and tool_result, including: - Long-running background tools - Many concurrent fast tools - Async/out-of-order tool results Both _identify_message_pairs() and _reorder_paired_messages() now use dictionary-based approach for O(n) complexity vs O(nΒ²). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 133 +++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 64 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index f91abef4..f7317acb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1985,7 +1985,24 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: """Identify and mark paired messages (e.g., command + output, tool use + result). Modifies messages in-place by setting is_paired and pair_role fields. + + Uses a two-pass algorithm: + 1. First pass: Build index of tool_use_id -> message index for tool_use and tool_result + 2. Second pass: Sequential scan for adjacent pairs (system+output, bash, thinking+assistant) + and match tool_use/tool_result using the index """ + # Pass 1: Build index of tool_use messages and tool_result messages by tool_use_id + tool_use_index: Dict[str, int] = {} # tool_use_id -> message index + tool_result_index: Dict[str, int] = {} # tool_use_id -> message index + + for i, msg in enumerate(messages): + if msg.tool_use_id: + if "tool_use" in msg.css_class: + tool_use_index[msg.tool_use_id] = i + elif "tool_result" in msg.css_class: + tool_result_index[msg.tool_use_id] = i + + # Pass 2: Sequential scan to identify pairs i = 0 while i < len(messages): current = messages[i] @@ -1995,7 +2012,7 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 1 continue - # Check for system command + command output pair + # Check for system command + command output pair (adjacent only) if current.css_class == "system" and i + 1 < len(messages): next_msg = messages[i + 1] if "command-output" in next_msg.css_class: @@ -2006,24 +2023,17 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 2 continue - # Check for tool_use + tool_result pair (match by tool_use_id) + # Check for tool_use + tool_result pair using index (no distance limit) if "tool_use" in current.css_class and current.tool_use_id: - # Look ahead for matching tool_result - for j in range( - i + 1, min(i + 10, len(messages)) - ): # Look ahead up to 10 messages - next_msg = messages[j] - if ( - "tool_result" in next_msg.css_class - and next_msg.tool_use_id == current.tool_use_id - ): - current.is_paired = True - current.pair_role = "pair_first" - next_msg.is_paired = True - next_msg.pair_role = "pair_last" - break + if current.tool_use_id in tool_result_index: + result_idx = tool_result_index[current.tool_use_id] + result_msg = messages[result_idx] + current.is_paired = True + current.pair_role = "pair_first" + result_msg.is_paired = True + result_msg.pair_role = "pair_last" - # Check for bash-input + bash-output pair + # Check for bash-input + bash-output pair (adjacent only) if current.css_class == "bash-input" and i + 1 < len(messages): next_msg = messages[i + 1] if next_msg.css_class == "bash-output": @@ -2034,7 +2044,7 @@ def _identify_message_pairs(messages: List[TemplateMessage]) -> None: i += 2 continue - # Check for thinking + assistant pair + # Check for thinking + assistant pair (adjacent only) if "thinking" in current.css_class and i + 1 < len(messages): next_msg = messages[i + 1] if "assistant" in next_msg.css_class: @@ -2054,23 +2064,19 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe - Unpaired messages and first messages in pairs maintain chronological order - Last messages in pairs are moved immediately after their first message - Timestamps are enhanced to show duration for paired messages + + Uses dictionary-based approach to find pairs efficiently: + 1. Build index of all pair_last messages by tool_use_id + 2. Single pass through messages, inserting pair_last immediately after pair_first """ from datetime import datetime - # Build a map of tool_use_id to pair indices - pair_map: Dict[str, tuple[int, int]] = {} # tool_use_id -> (first_idx, last_idx) + # Build index of pair_last messages by tool_use_id + pair_last_index: Dict[str, int] = {} # tool_use_id -> message index for i, msg in enumerate(messages): - if msg.is_paired and msg.pair_role == "pair_first" and msg.tool_use_id: - # Find the matching pair_last - for j in range(i + 1, len(messages)): - if ( - messages[j].is_paired - and messages[j].pair_role == "pair_last" - and messages[j].tool_use_id == msg.tool_use_id - ): - pair_map[msg.tool_use_id] = (i, j) - break + if msg.is_paired and msg.pair_role == "pair_last" and msg.tool_use_id: + pair_last_index[msg.tool_use_id] = i # Create reordered list reordered: List[TemplateMessage] = [] @@ -2084,40 +2090,39 @@ def _reorder_paired_messages(messages: List[TemplateMessage]) -> List[TemplateMe # If this is the first message in a pair, immediately add its pair_last if msg.is_paired and msg.pair_role == "pair_first" and msg.tool_use_id: - if msg.tool_use_id in pair_map: - first_idx, last_idx = pair_map[msg.tool_use_id] - if first_idx == i: # Confirm this is the right pair - pair_last = messages[last_idx] - reordered.append(pair_last) - skip_indices.add(last_idx) - - # Calculate duration between pair messages - try: - if msg.raw_timestamp and pair_last.raw_timestamp: - # Parse ISO timestamps - first_time = datetime.fromisoformat( - msg.raw_timestamp.replace("Z", "+00:00") - ) - last_time = datetime.fromisoformat( - pair_last.raw_timestamp.replace("Z", "+00:00") - ) - duration = last_time - first_time - - # Format duration nicely - total_seconds = duration.total_seconds() - if total_seconds < 1: - duration_str = f"took {int(total_seconds * 1000)} ms" - elif total_seconds < 60: - duration_str = f"took {total_seconds:.1f}s" - else: - minutes = int(total_seconds // 60) - seconds = int(total_seconds % 60) - duration_str = f"took {minutes}m {seconds}s" - - # Store duration in pair_last for template rendering - pair_last.pair_duration = duration_str - except (ValueError, AttributeError): - pass + if msg.tool_use_id in pair_last_index: + last_idx = pair_last_index[msg.tool_use_id] + pair_last = messages[last_idx] + reordered.append(pair_last) + skip_indices.add(last_idx) + + # Calculate duration between pair messages + try: + if msg.raw_timestamp and pair_last.raw_timestamp: + # Parse ISO timestamps + first_time = datetime.fromisoformat( + msg.raw_timestamp.replace("Z", "+00:00") + ) + last_time = datetime.fromisoformat( + pair_last.raw_timestamp.replace("Z", "+00:00") + ) + duration = last_time - first_time + + # Format duration nicely + total_seconds = duration.total_seconds() + if total_seconds < 1: + duration_str = f"took {int(total_seconds * 1000)} ms" + elif total_seconds < 60: + duration_str = f"took {total_seconds:.1f}s" + else: + minutes = int(total_seconds // 60) + seconds = int(total_seconds % 60) + duration_str = f"took {minutes}m {seconds}s" + + # Store duration in pair_last for template rendering + pair_last.pair_duration = duration_str + except (ValueError, AttributeError): + pass return reordered From eadc0a1fc620f571ae4270bbfd7cf249b15e837a Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 6 Nov 2025 16:41:42 +0100 Subject: [PATCH 23/35] Move line count next to expand arrow for vertical space savings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed collapsible code structure from: 43 lines (click to expand)
...
To: 43 lines
...
Benefits: - Line count appears inline with disclosure triangle (β–Ά) - Preview code moves outside summary for cleaner layout - Saves vertical space by eliminating redundant label - More compact, matches user's requested design Updated CSS to: - Style .line-count inline instead of block - Show preview when closed: .collapsible-code:not([open]) .code-preview - Hide preview when open: .collapsible-code[open] .code-preview πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 18 ++++------ .../templates/components/pygments_styles.css | 33 ++++++++++++------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index f7317acb..5cdb4144 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -400,10 +400,8 @@ def format_write_tool_content(tool_use: ToolUseContent) -> str: html_parts.append(f"""
- - {len(lines)} lines (click to expand) -
{preview_html}
-
+ {len(lines)} lines +
{preview_html}
{highlighted_html}
""") @@ -944,10 +942,8 @@ def format_tool_result_content( result_parts.append(f"""
- - {len(lines)} lines (click to expand) -
{preview_html}
-
+ {len(lines)} lines +
{preview_html}
{highlighted_html}
""") @@ -990,10 +986,8 @@ def format_tool_result_content( result_parts.append(f"""
- - {len(lines)} lines (click to expand) -
{preview_html}
-
+ {len(lines)} lines +
{preview_html}
{highlighted_html}
""") diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 85003957..a39fd373 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -90,11 +90,10 @@ background: var(--color-bg-tertiary); } -.read-tool-result .code-preview-label { - font-weight: 600; +.read-tool-result .line-count { + font-size: 0.9em; color: var(--color-text-secondary); - display: block; - margin-bottom: 0.5em; + margin-left: 0.5em; } .read-tool-result .code-preview { @@ -104,6 +103,10 @@ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); } +.read-tool-result .collapsible-code:not([open]) .code-preview { + display: block; +} + .read-tool-result .collapsible-code[open] .code-preview { display: none; } @@ -144,11 +147,10 @@ background: var(--color-bg-tertiary); } -.edit-tool-result .code-preview-label { - font-weight: 600; +.edit-tool-result .line-count { + font-size: 0.9em; color: var(--color-text-secondary); - display: block; - margin-bottom: 0.5em; + margin-left: 0.5em; } .edit-tool-result .code-preview { @@ -158,6 +160,10 @@ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); } +.edit-tool-result .collapsible-code:not([open]) .code-preview { + display: block; +} + .edit-tool-result .collapsible-code[open] .code-preview { display: none; } @@ -228,11 +234,10 @@ background: var(--color-bg-tertiary); } -.write-tool-content .code-preview-label { - font-weight: 600; +.write-tool-content .line-count { + font-size: 0.9em; color: var(--color-text-secondary); - display: block; - margin-bottom: 0.5em; + margin-left: 0.5em; } .write-tool-content .code-preview { @@ -242,6 +247,10 @@ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); } +.write-tool-content .collapsible-code:not([open]) .code-preview { + display: block; +} + .write-tool-content .collapsible-code[open] .code-preview { display: none; } From 650fd9cabf811bede2a1b93683e4e04fb4e6e058 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 6 Nov 2025 16:45:15 +0100 Subject: [PATCH 24/35] Fix: Keep code preview inside summary tag for correct visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit incorrectly moved code-preview outside , which would make it always visible due to browser's native
behavior. Content outside summary is visible by default, not hidden when details is closed. Correct structure:
43 lines
...
...
Removed unnecessary :not([open]) CSS rules since preview is back inside summary where it's visible by default unless explicitly hidden. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 18 ++++++++++++------ .../templates/components/pygments_styles.css | 12 ------------ 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 5cdb4144..62b81913 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -400,8 +400,10 @@ def format_write_tool_content(tool_use: ToolUseContent) -> str: html_parts.append(f"""
- {len(lines)} lines -
{preview_html}
+ + {len(lines)} lines +
{preview_html}
+
{highlighted_html}
""") @@ -942,8 +944,10 @@ def format_tool_result_content( result_parts.append(f"""
- {len(lines)} lines -
{preview_html}
+ + {len(lines)} lines +
{preview_html}
+
{highlighted_html}
""") @@ -986,8 +990,10 @@ def format_tool_result_content( result_parts.append(f"""
- {len(lines)} lines -
{preview_html}
+ + {len(lines)} lines +
{preview_html}
+
{highlighted_html}
""") diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index a39fd373..65885720 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -103,10 +103,6 @@ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); } -.read-tool-result .collapsible-code:not([open]) .code-preview { - display: block; -} - .read-tool-result .collapsible-code[open] .code-preview { display: none; } @@ -160,10 +156,6 @@ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); } -.edit-tool-result .collapsible-code:not([open]) .code-preview { - display: block; -} - .edit-tool-result .collapsible-code[open] .code-preview { display: none; } @@ -247,10 +239,6 @@ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); } -.write-tool-content .collapsible-code:not([open]) .code-preview { - display: block; -} - .write-tool-content .collapsible-code[open] .code-preview { display: none; } From b5d6f0aa2209287e2acfdc113b0b65309b9b8857 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 6 Nov 2025 20:12:06 +0100 Subject: [PATCH 25/35] Unify and consolidate tool preview and collapsible styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes to renderer.py: - Rename .code-preview to .preview-content in collapsible code blocks (3 occurrences) Changes to message_styles.css: - Add consolidated .collapsible-code rules for all .tool_result elements - Change padding to padding-top only (eliminates horizontal jump on fold/unfold) - Add gradient fade effect for all .tool_result .preview-content - Consolidate hide-on-open rules for both .collapsible-details and .collapsible-code Changes to pygments_styles.css: - Remove all tool-specific .collapsible-code rules (read/edit/write) - Remove all tool-specific .preview-content rules - Significantly reduced duplication (93 lines removed) Benefits: - Single source of truth for collapsible code styling - Consistent behavior across all tools (Bash, Read, Edit, Write) - Eliminates vertical/horizontal jump when folding/unfolding - Unified gradient fade effect for all tool previews - Much cleaner separation of concerns (Pygments CSS only for syntax highlighting) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 6 +- .../templates/components/message_styles.css | 28 +++++- .../templates/components/pygments_styles.css | 93 ------------------- 3 files changed, 29 insertions(+), 98 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 62b81913..f21e280b 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -402,7 +402,7 @@ def format_write_tool_content(tool_use: ToolUseContent) -> str:
{len(lines)} lines -
{preview_html}
+
{preview_html}
{highlighted_html}
@@ -946,7 +946,7 @@ def format_tool_result_content(
{len(lines)} lines -
{preview_html}
+
{preview_html}
{highlighted_html}
@@ -992,7 +992,7 @@ def format_tool_result_content(
{len(lines)} lines -
{preview_html}
+
{preview_html}
{highlighted_html}
diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index e3729016..2b4dc4ea 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -498,13 +498,37 @@ details summary { cursor: pointer; } +/* Collapsible code blocks (Read/Edit/Write tools) */ +.tool_result .collapsible-code { + margin-top: -2.5em; +} + +.tool_result .collapsible-code summary { + cursor: pointer; + padding-top: 0.5em; + background: var(--color-bg-secondary); + border-radius: 4px; +} + +.tool_result .collapsible-code summary:hover { + background: var(--color-bg-tertiary); +} + /* Preview content styling - shown when closed */ .collapsible-details:not([open]) .preview-content { margin-top: 4px; } -/* Hide preview content when details is open */ -.collapsible-details[open] .preview-content { +/* Tool result preview content with gradient fade */ +.tool_result .preview-content { + opacity: 0.7; + mask-image: linear-gradient(to bottom, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); +} + +/* Hide preview content when details/collapsible is open */ +.collapsible-details[open] .preview-content, +.collapsible-code[open] .preview-content { display: none; } diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index 65885720..ef42d2c9 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -74,43 +74,12 @@ margin: 0.5em 0; } -.read-tool-result .collapsible-code { - margin-top: -2.5em; -} - -.read-tool-result .collapsible-code summary { - cursor: pointer; - padding: 0.5em; - background: var(--color-bg-secondary); - border-radius: 4px; - margin-bottom: 0.5em; -} - -.read-tool-result .collapsible-code summary:hover { - background: var(--color-bg-tertiary); -} - .read-tool-result .line-count { font-size: 0.9em; color: var(--color-text-secondary); margin-left: 0.5em; } -.read-tool-result .code-preview { - margin-top: 0.5em; - opacity: 0.7; - mask-image: linear-gradient(to bottom, black 80%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -} - -.read-tool-result .collapsible-code[open] .code-preview { - display: none; -} - -.read-tool-result .code-full { - margin-top: 0.5em; -} - .read-tool-result .system-reminder { margin-top: 0.5em; padding: 0.5em; @@ -127,43 +96,12 @@ margin: 0.5em 0; } -.edit-tool-result .collapsible-code { - margin-top: -2.5em; -} - -.edit-tool-result .collapsible-code summary { - cursor: pointer; - padding: 0.5em; - background: var(--color-bg-secondary); - border-radius: 4px; - margin-bottom: 0.5em; -} - -.edit-tool-result .collapsible-code summary:hover { - background: var(--color-bg-tertiary); -} - .edit-tool-result .line-count { font-size: 0.9em; color: var(--color-text-secondary); margin-left: 0.5em; } -.edit-tool-result .code-preview { - margin-top: 0.5em; - opacity: 0.7; - mask-image: linear-gradient(to bottom, black 80%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -} - -.edit-tool-result .collapsible-code[open] .code-preview { - display: none; -} - -.edit-tool-result .code-full { - margin-top: 0.5em; -} - /* Multiedit tool specific styles */ .multiedit-tool-content { margin: 0.5em 0; @@ -210,43 +148,12 @@ padding-left: 1.5em; } -.write-tool-content .collapsible-code { - margin-top: -2.5em; -} - -.write-tool-content .collapsible-code summary { - cursor: pointer; - padding: 0.5em; - background: var(--color-bg-secondary); - border-radius: 4px; - margin-bottom: 0.5em; -} - -.write-tool-content .collapsible-code summary:hover { - background: var(--color-bg-tertiary); -} - .write-tool-content .line-count { font-size: 0.9em; color: var(--color-text-secondary); margin-left: 0.5em; } -.write-tool-content .code-preview { - margin-top: 0.5em; - opacity: 0.7; - mask-image: linear-gradient(to bottom, black 80%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%); -} - -.write-tool-content .collapsible-code[open] .code-preview { - display: none; -} - -.write-tool-content .code-full { - margin-top: 0.5em; -} - /* Pygments token styles (based on 'default' theme) */ .highlight { background: var(--color-bg-secondary); } .highlight .hll { background-color: #ffffcc } From 795242150ec37d7cf340deed39bce96bddd84f8b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 7 Nov 2025 00:01:06 +0100 Subject: [PATCH 26/35] Refactor: Separate message_type from message_title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a clean separation between message_type (used for CSS classes and internal logic) and message_title (used for display text in headers). Changes: - Added message_title parameter to TemplateMessage class with sensible default (message_type.title()) - Updated all message processing functions to return 4-tuple: (css_class, content_html, message_type, message_title) - Removed __CUSTOM_ICON__ hack that was prefixing display strings - Updated template to use message.message_title instead of display_type - Simplified template logic by removing __CUSTOM_ICON__ checks - Set message_type to lowercase identifiers (user, assistant, system, tool_use, tool_result, thinking, image, unknown) - Set message_title to display-ready strings with proper capitalization and custom formatting where needed Benefits: - Cleaner separation of concerns - Eliminates hacky string prefix checking - More maintainable code - Consistent message type identifiers πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 119 +++++++++++++--------- claude_code_log/templates/transcript.html | 6 +- 2 files changed, 74 insertions(+), 51 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index f21e280b..4ff7654d 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1378,13 +1378,17 @@ def __init__( tool_use_id: Optional[str] = None, title_hint: Optional[str] = None, has_markdown: bool = False, + message_title: Optional[str] = None, ): self.type = message_type self.content_html = content_html self.formatted_timestamp = formatted_timestamp self.css_class = css_class self.raw_timestamp = raw_timestamp - self.display_type = message_type + # Display title for message header (capitalized, with decorations) + self.message_title = ( + message_title if message_title is not None else message_type.title() + ) self.session_summary = session_summary self.session_id = session_id self.is_session_header = is_session_header @@ -1801,8 +1805,8 @@ def _convert_ansi_to_html(text: str) -> str: # return css_class, content_html, message_type -def _process_command_message(text_content: str) -> tuple[str, str, str]: - """Process a command message and return (css_class, content_html, message_type).""" +def _process_command_message(text_content: str) -> tuple[str, str, str, str]: + """Process a command message and return (css_class, content_html, message_type, message_title).""" css_class = "system" command_name, command_args, command_contents = extract_command_info(text_content) escaped_command_name = escape_html(command_name) @@ -1822,11 +1826,12 @@ def _process_command_message(text_content: str) -> tuple[str, str, str]: content_html = "
".join(content_parts) message_type = "system" - return css_class, content_html, message_type + message_title = "System" + return css_class, content_html, message_type, message_title -def _process_local_command_output(text_content: str) -> tuple[str, str, str]: - """Process local command output and return (css_class, content_html, message_type).""" +def _process_local_command_output(text_content: str) -> tuple[str, str, str, str]: + """Process local command output and return (css_class, content_html, message_type, message_title).""" import re css_class = "system command-output" @@ -1863,11 +1868,12 @@ def _process_local_command_output(text_content: str) -> tuple[str, str, str]: content_html = escape_html(text_content) message_type = "system" - return css_class, content_html, message_type + message_title = "System" + return css_class, content_html, message_type, message_title -def _process_bash_input(text_content: str) -> tuple[str, str, str]: - """Process bash input command and return (css_class, content_html, message_type).""" +def _process_bash_input(text_content: str) -> tuple[str, str, str, str]: + """Process bash input command and return (css_class, content_html, message_type, message_title).""" import re css_class = "bash-input" @@ -1888,11 +1894,12 @@ def _process_bash_input(text_content: str) -> tuple[str, str, str]: content_html = escape_html(text_content) message_type = "bash" - return css_class, content_html, message_type + message_title = "Bash" + return css_class, content_html, message_type, message_title -def _process_bash_output(text_content: str) -> tuple[str, str, str]: - """Process bash output and return (css_class, content_html, message_type).""" +def _process_bash_output(text_content: str) -> tuple[str, str, str, str]: + """Process bash output and return (css_class, content_html, message_type, message_title).""" import re css_class = "bash-output" @@ -1930,16 +1937,18 @@ def _process_bash_output(text_content: str) -> tuple[str, str, str]: ) message_type = "bash" - return css_class, content_html, message_type + message_title = "Bash" + return css_class, content_html, message_type, message_title def _process_regular_message( text_only_content: List[ContentItem], message_type: str, is_sidechain: bool, -) -> tuple[str, str, str]: - """Process regular message and return (css_class, content_html, message_type).""" +) -> tuple[str, str, str, str]: + """Process regular message and return (css_class, content_html, message_type, message_title).""" css_class = f"{message_type}" + message_title = message_type.title() # Default title # Handle user-specific preprocessing if message_type == "user": @@ -1951,7 +1960,7 @@ def _process_regular_message( content_html, is_compacted = render_user_message_content(text_only_content) if is_compacted: css_class = f"{message_type} compacted" - message_type = "πŸ€– User (compacted conversation)" + message_title = "User (compacted conversation)" else: # Non-user messages: render directly content_html = render_message_content(text_only_content, message_type) @@ -1959,15 +1968,13 @@ def _process_regular_message( if is_sidechain: css_class = f"{css_class} sidechain" - # Update message type for display - if not is_compacted: # Don't override compacted message type - message_type = ( - "πŸ“ Sub-assistant prompt" - if message_type == "user" - else "πŸ”— Sub-assistant" + # Update message title for display + if not is_compacted: # Don't override compacted message title + message_title = ( + "Sub-assistant prompt" if message_type == "user" else "Sub-assistant" ) - return css_class, content_html, message_type + return css_class, content_html, message_type, message_title def _get_combined_transcript_link(cache_manager: "CacheManager") -> Optional[str]: @@ -2263,12 +2270,13 @@ def generate_html( content_html = f"{level_icon} {html_content}" system_template_message = TemplateMessage( - message_type=f"System {level.title()}", + message_type="system", content_html=content_html, formatted_timestamp=formatted_timestamp, css_class=level_css, raw_timestamp=timestamp, session_id=session_id, + message_title=f"System {level.title()}", ) template_messages.append(system_template_message) continue @@ -2452,20 +2460,28 @@ def generate_html( # Determine CSS class and content based on message type and duplicate status if is_command: - css_class, content_html, message_type = _process_command_message( - text_content + css_class, content_html, message_type, message_title = ( + _process_command_message(text_content) ) elif is_local_output: - css_class, content_html, message_type = _process_local_command_output( - text_content + css_class, content_html, message_type, message_title = ( + _process_local_command_output(text_content) ) elif is_bash_cmd: - css_class, content_html, message_type = _process_bash_input(text_content) + css_class, content_html, message_type, message_title = _process_bash_input( + text_content + ) elif is_bash_result: - css_class, content_html, message_type = _process_bash_output(text_content) + css_class, content_html, message_type, message_title = _process_bash_output( + text_content + ) else: - css_class, content_html, message_type = _process_regular_message( - text_only_content, message_type, getattr(message, "isSidechain", False) + css_class, content_html, message_type, message_title = ( + _process_regular_message( + text_only_content, + message_type, + getattr(message, "isSidechain", False), + ) ) # Create main message (if it has text content) @@ -2479,6 +2495,7 @@ def generate_html( session_summary=session_summary, session_id=session_id, token_usage=token_usage_str, + message_title=message_title, ) template_messages.append(template_message) @@ -2515,30 +2532,30 @@ def generate_html( # Get summary for header (description or filepath) summary = get_tool_summary(tool_use_converted) - # Use simplified display names without "Tool Use:" prefix - # Mark tools with custom icons using a prefix + # Set message_type (for CSS/logic) and message_title (for display) + tool_message_type = "tool_use" if tool_use_converted.name == "TodoWrite": - tool_message_type = "__CUSTOM_ICON__πŸ“ Todo List" + tool_message_title = "πŸ“ Todo List" elif tool_use_converted.name in ("Edit", "Write"): - # Use πŸ“ icon for Edit/Write - mark with prefix to skip generic icon + # Use πŸ“ icon for Edit/Write if summary: escaped_summary = escape_html(summary) - tool_message_type = f"__CUSTOM_ICON__πŸ“ {escaped_name} {escaped_summary}" + tool_message_title = f"πŸ“ {escaped_name} {escaped_summary}" else: - tool_message_type = f"__CUSTOM_ICON__πŸ“ {escaped_name}" + tool_message_title = f"πŸ“ {escaped_name}" elif tool_use_converted.name == "Read": - # Use πŸ“„ icon for Read - mark with prefix to skip generic icon + # Use πŸ“„ icon for Read if summary: escaped_summary = escape_html(summary) - tool_message_type = f"__CUSTOM_ICON__πŸ“„ {escaped_name} {escaped_summary}" + tool_message_title = f"πŸ“„ {escaped_name} {escaped_summary}" else: - tool_message_type = f"__CUSTOM_ICON__πŸ“„ {escaped_name}" + tool_message_title = f"πŸ“„ {escaped_name}" elif summary: # For other tools (like Bash), append summary escaped_summary = escape_html(summary) - tool_message_type = f"{escaped_name} {escaped_summary}" + tool_message_title = f"{escaped_name} {escaped_summary}" else: - tool_message_type = escaped_name + tool_message_title = escaped_name tool_css_class = "tool_use" elif isinstance(tool_item, ToolResultContent) or item_type == "tool_result": # Convert Anthropic type to our format if necessary @@ -2572,8 +2589,10 @@ def generate_html( item_tool_use_id = tool_result_converted.tool_use_id tool_title_hint = f"ID: {escaped_id}" # Simplified: no "Tool Result" heading, just show error indicator if present - error_indicator = "🚨 Error" if tool_result_converted.is_error else "" - tool_message_type = error_indicator if error_indicator else "" + tool_message_type = "tool_result" + tool_message_title = ( + "🚨 Error" if tool_result_converted.is_error else "" + ) tool_css_class = ( "tool_result error" if tool_result_converted.is_error @@ -2590,7 +2609,8 @@ def generate_html( thinking_converted = tool_item tool_content_html = format_thinking_content(thinking_converted) - tool_message_type = "Thinking" + tool_message_type = "thinking" + tool_message_title = "Thinking" tool_css_class = "thinking" elif isinstance(tool_item, ImageContent) or item_type == "image": # Convert Anthropic type to our format if necessary @@ -2599,14 +2619,16 @@ def generate_html( continue else: tool_content_html = format_image_content(tool_item) - tool_message_type = "Image" + tool_message_type = "image" + tool_message_title = "Image" tool_css_class = "image" else: # Handle unknown content types tool_content_html = ( f"

Unknown content type: {escape_html(str(type(tool_item)))}

" ) - tool_message_type = "Unknown Content" + tool_message_type = "unknown" + tool_message_title = "Unknown Content" tool_css_class = "unknown" # Preserve sidechain context for tool/thinking/image content within sidechain messages @@ -2623,6 +2645,7 @@ def generate_html( session_id=session_id, tool_use_id=item_tool_use_id, title_hint=tool_title_hint, + message_title=tool_message_title, ) template_messages.append(tool_template_message) diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 5cc8ec09..5fd35f2d 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -84,13 +84,13 @@

πŸ” Search & Filter

{% set markdown = message.css_class in ['assistant', 'thinking', 'sidechain'] or (message.css_class and 'compacted' in message.css_class) %}
- {% if message.display_type %}{% + {% if message.message_title %}{% if message.css_class == 'user' %}🀷 {% elif message.css_class == 'assistant' %}πŸ€– {% elif message.css_class == 'system' %}βš™οΈ {% - elif message.css_class == 'tool_use' and not message.display_type.startswith('__CUSTOM_ICON__') %}πŸ› οΈ {% + elif message.css_class == 'tool_use' %}πŸ› οΈ {% elif message.css_class == 'tool_result' %}🧰 {% elif message.css_class == 'thinking' %}πŸ’­ {% - elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.display_type.replace('__CUSTOM_ICON__', '') | safe }}{% endif %} + elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.message_title | safe }}{% endif %}
{{ message.formatted_timestamp }} From 81641ab78135eb02f82295ec9f9a31a2aece5d6f Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 7 Nov 2025 00:03:26 +0100 Subject: [PATCH 27/35] Use
 for Write tool result output for consistency
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Changes Write tool result rendering from 

to

 tag to match
the styling of other tool results like Read, Edit, and Bash.

πŸ€– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude 
---
 claude_code_log/renderer.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py
index 4ff7654d..62d72503 100644
--- a/claude_code_log/renderer.py
+++ b/claude_code_log/renderer.py
@@ -917,7 +917,7 @@ def format_tool_result_content(
             # Keep only the first acknowledgment line and add ellipsis
             first_line = lines[0]
             escaped_html = escape_html(first_line)
-            return f"

{escaped_html} ...

" + 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: From 0813fa7e3c323ea816f9553caf638d4fe31a3292 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 7 Nov 2025 00:09:09 +0100 Subject: [PATCH 28/35] Fix: Remove duplicate tool icons and restore sidechain icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two issues introduced in the message_title refactoring: 1. Tool use messages were showing duplicate icons - the generic πŸ› οΈ from the template plus the specific icon (πŸ“, πŸ“„) from message_title. Fixed by removing the generic tool_use icon from the template since each tool already has its specific icon in message_title. 2. Sidechain messages lost their icons (πŸ“ for prompt, πŸ”— for assistant). Fixed by restoring emoji prefixes to sidechain message titles. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 4 +++- claude_code_log/templates/transcript.html | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 62d72503..2991f553 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1971,7 +1971,9 @@ def _process_regular_message( # Update message title for display if not is_compacted: # Don't override compacted message title message_title = ( - "Sub-assistant prompt" if message_type == "user" else "Sub-assistant" + "πŸ“ Sub-assistant prompt" + if message_type == "user" + else "πŸ”— Sub-assistant" ) return css_class, content_html, message_type, message_title diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 5fd35f2d..50084051 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -88,8 +88,8 @@

πŸ” Search & Filter

if message.css_class == 'user' %}🀷 {% elif message.css_class == 'assistant' %}πŸ€– {% elif message.css_class == 'system' %}βš™οΈ {% - elif message.css_class == 'tool_use' %}πŸ› οΈ {% - elif message.css_class == 'tool_result' %}🧰 {% elif message.css_class == 'thinking' %}πŸ’­ {% + elif message.css_class == 'tool_result' %}🧰 {% + elif message.css_class == 'thinking' %}πŸ’­ {% elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.message_title | safe }}{% endif %}
From eb2d3ac50bd5cf22d3f1748976c952fec1cb1179 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 7 Nov 2025 00:14:43 +0100 Subject: [PATCH 29/35] Add starts_with_emoji helper for conditional tool icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a helper function to detect if a string starts with an emoji, allowing the template to conditionally add the generic πŸ› οΈ tool icon only when the message_title doesn't already have a custom emoji. Changes: - Added starts_with_emoji() function checking common emoji Unicode ranges: * Emoticons (U+1F600 - U+1F64F) * Misc Symbols and Pictographs (U+1F300 - U+1F5FF) * Transport and Map Symbols (U+1F680 - U+1F6FF) * Supplemental Symbols (U+1F900 - U+1F9FF) * Misc Symbols (U+2600 - U+26FF) * Dingbats (U+2700 - U+27BF) - Registered function in Jinja2 environment globals - Updated template to conditionally add πŸ› οΈ for tool_use only when message_title doesn't start with an emoji This solves both cases: - Generic tools (Bash, Grep, etc.) get the πŸ› οΈ icon - Specialized tools (πŸ“ Edit/Write, πŸ“„ Read) keep only their custom icon πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 32 ++++++++++++++++++++++- claude_code_log/templates/transcript.html | 1 + 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2991f553..a13f11b1 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -41,6 +41,33 @@ from .cache import get_library_version +def starts_with_emoji(text: str) -> bool: + """Check if a string starts with an emoji character. + + Checks common emoji Unicode ranges: + - Emoticons: U+1F600 - U+1F64F + - Misc Symbols and Pictographs: U+1F300 - U+1F5FF + - Transport and Map Symbols: U+1F680 - U+1F6FF + - Supplemental Symbols: U+1F900 - U+1F9FF + - Misc Symbols: U+2600 - U+26FF + - Dingbats: U+2700 - U+27BF + """ + if not text: + return False + + first_char = text[0] + code_point = ord(first_char) + + return ( + 0x1F600 <= code_point <= 0x1F64F # Emoticons + or 0x1F300 <= code_point <= 0x1F5FF # Misc Symbols and Pictographs + or 0x1F680 <= code_point <= 0x1F6FF # Transport and Map Symbols + or 0x1F900 <= code_point <= 0x1F9FF # Supplemental Symbols + or 0x2600 <= code_point <= 0x26FF # Misc Symbols + or 0x2700 <= code_point <= 0x27BF # Dingbats + ) + + def get_project_display_name( project_dir_name: str, working_directories: Optional[List[str]] = None ) -> str: @@ -1355,10 +1382,13 @@ def render_message_content(content: List[ContentItem], message_type: str) -> str def _get_template_environment() -> Environment: """Get Jinja2 template environment.""" templates_dir = Path(__file__).parent / "templates" - return Environment( + env = Environment( loader=FileSystemLoader(templates_dir), autoescape=select_autoescape(["html", "xml"]), ) + # Add custom filters/functions + env.globals["starts_with_emoji"] = starts_with_emoji + return env class TemplateMessage: diff --git a/claude_code_log/templates/transcript.html b/claude_code_log/templates/transcript.html index 50084051..31186794 100644 --- a/claude_code_log/templates/transcript.html +++ b/claude_code_log/templates/transcript.html @@ -88,6 +88,7 @@

πŸ” Search & Filter

if message.css_class == 'user' %}🀷 {% elif message.css_class == 'assistant' %}πŸ€– {% elif message.css_class == 'system' %}βš™οΈ {% + elif message.css_class == 'tool_use' and not starts_with_emoji(message.message_title) %}πŸ› οΈ {% elif message.css_class == 'tool_result' %}🧰 {% elif message.css_class == 'thinking' %}πŸ’­ {% elif message.css_class == 'image' %}πŸ–ΌοΈ {% endif %}{{ message.message_title | safe }}{% endif %} From ac5121a6b1f3a546f746d7288e5d887287c7a98b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 7 Nov 2025 00:24:27 +0100 Subject: [PATCH 30/35] Fix: Make image preview inline and unify CSS rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes the Read tool result image preview to use inline span and consolidates three duplicate CSS rules into a single unified rule. Changes: - Use 'preview-text' class for image content summary (more semantic) - Unified .line-count and .preview-text styling under single rule: `.tool-result .line-count, .tool-result .preview-text` - Removed duplicate rules from .edit-tool-result and .write-tool-content - Text now appears inline with expander arrow, saving vertical space Benefits: - Consistent visual treatment between code and image previews - Reduced CSS redundancy (3 rules β†’ 1) - More maintainable stylesheet πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 4 ++-- .../templates/components/pygments_styles.css | 15 +++------------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index a13f11b1..249779f2 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1046,11 +1046,11 @@ def format_tool_result_content( combined_content = f"{text_html}{images_html}" # Always make collapsible when images are present - preview_text = "Text and image content (click to expand)" + preview_text = "Text and image content" return f"""
-
{preview_text}
+ {preview_text}
{combined_content} diff --git a/claude_code_log/templates/components/pygments_styles.css b/claude_code_log/templates/components/pygments_styles.css index ef42d2c9..eb1fb407 100644 --- a/claude_code_log/templates/components/pygments_styles.css +++ b/claude_code_log/templates/components/pygments_styles.css @@ -74,7 +74,9 @@ margin: 0.5em 0; } -.read-tool-result .line-count { +/* Unified styling for inline preview text in tool results */ +.tool-result .line-count, +.tool-result .preview-text { font-size: 0.9em; color: var(--color-text-secondary); margin-left: 0.5em; @@ -96,12 +98,6 @@ margin: 0.5em 0; } -.edit-tool-result .line-count { - font-size: 0.9em; - color: var(--color-text-secondary); - margin-left: 0.5em; -} - /* Multiedit tool specific styles */ .multiedit-tool-content { margin: 0.5em 0; @@ -148,11 +144,6 @@ padding-left: 1.5em; } -.write-tool-content .line-count { - font-size: 0.9em; - color: var(--color-text-secondary); - margin-left: 0.5em; -} /* Pygments token styles (based on 'default' theme) */ .highlight { background: var(--color-bg-secondary); } From a260c70a972337e66c72ff35970d80f90602134c Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 7 Nov 2025 00:52:14 +0100 Subject: [PATCH 31/35] Fix: Update test expectations for recent changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates test assertions to match recent refactoring changes: 1. test_template_data.py: - Changed display_type to message_title in TemplateMessage tests - Renamed test method to test_template_message_title_capitalization 2. test_tool_result_image_rendering.py: - Removed "(click to expand)" from expected text (simplified to "Text and image content" for vertical space savings) 3. test_todowrite_rendering.py: - Removed edit-file-path assertion (file path moved to message header) - Added comment explaining the architectural change All tests now pass (244 passed, 58 deselected). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- test/test_template_data.py | 8 ++++---- test/test_todowrite_rendering.py | 3 +-- test/test_tool_result_image_rendering.py | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/test/test_template_data.py b/test/test_template_data.py index 6ee0dcfc..b578fc80 100644 --- a/test/test_template_data.py +++ b/test/test_template_data.py @@ -31,10 +31,10 @@ def test_template_message_creation(self): assert msg.content_html == "

Test content

" assert msg.formatted_timestamp == "2025-06-14 10:00:00" assert msg.css_class == "user" - assert msg.display_type == "User" + assert msg.message_title == "User" - def test_template_message_display_type_capitalization(self): - """Test that display_type properly capitalizes message types.""" + def test_template_message_title_capitalization(self): + """Test that message_title properly capitalizes message types.""" test_cases = [ ("user", "User"), ("assistant", "Assistant"), @@ -50,7 +50,7 @@ def test_template_message_display_type_capitalization(self): css_class="class", raw_timestamp=None, ) - assert msg.display_type == expected_display + assert msg.message_title == expected_display class TestTemplateProject: diff --git a/test/test_todowrite_rendering.py b/test/test_todowrite_rendering.py index ac8ec4ac..933a7f5f 100644 --- a/test/test_todowrite_rendering.py +++ b/test/test_todowrite_rendering.py @@ -258,8 +258,7 @@ def test_todowrite_vs_regular_tool_use(self): # Edit tool should use diff formatting (not table) assert "edit-diff" in regular_html - assert "edit-file-path" in regular_html - assert "/tmp/test.py" in regular_html + # File path no longer in content, moved to message header # Tool name/ID no longer in content, moved to message header # TodoWrite should use special formatting diff --git a/test/test_tool_result_image_rendering.py b/test/test_tool_result_image_rendering.py index 83047748..755a979f 100644 --- a/test/test_tool_result_image_rendering.py +++ b/test/test_tool_result_image_rendering.py @@ -32,7 +32,7 @@ def test_tool_result_with_image(): # Should be collapsible when images are present assert '
' in html assert "" in html - assert "Text and image content (click to expand)" in html + assert "Text and image content" in html # Should contain the text assert "Screenshot captured successfully" in html @@ -71,7 +71,7 @@ def test_tool_result_with_only_image(): # Should be collapsible assert '
' in html - assert "Text and image content (click to expand)" in html + assert "Text and image content" in html # Should contain the image with JPEG media type assert f"data:image/jpeg;base64,{sample_image_data}" in html From 5f8b93199dded65ff668feda6219314dad86f2cd Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Fri, 7 Nov 2025 00:58:29 +0100 Subject: [PATCH 32/35] Fix: Add type ignore for Jinja2 env.globals type checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppresses pyright error at line 1390 where we add a custom function to Jinja2's env.globals dictionary. The error occurs because Jinja2's type stubs are overly restrictive and only allow specific built-in types (range, dict, Cycler, etc.) rather than accepting arbitrary callable types. This is a known limitation of the Jinja2 type stubs. Our starts_with_emoji function works correctly at runtime; this is purely a type checking limitation. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 249779f2..32bbf57a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -1387,7 +1387,7 @@ def _get_template_environment() -> Environment: autoescape=select_autoescape(["html", "xml"]), ) # Add custom filters/functions - env.globals["starts_with_emoji"] = starts_with_emoji + env.globals["starts_with_emoji"] = starts_with_emoji # type: ignore[index] return env From add587291f6bc8f613b15fe03047fd5718568539 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 8 Nov 2025 00:39:11 +0100 Subject: [PATCH 33/35] Fix: Deduplicate version stutters while preserving streaming fragments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem:** Claude Code can record duplicate message pairs when upgrading mid-session (e.g., v2.0.31 β†’ v2.0.34), causing tool results to appear multiple times in generated HTML reports. These "version stutters" have: - Same message.id (for assistant) or tool_use_id (for tool results) - Different Claude Code version numbers - Different UUIDs but identical content and timestamps Additionally, normal streaming responses create message fragments with: - Same message.id and same version - Different content (text fragment + tool_use fragment) - These must remain separate (not merged or deduplicated) **Solution:** Group messages by unique identifier (message.id or tool_use_id), then: 1. If all same version β†’ Keep ALL (streaming fragments) 2. If different versions β†’ Keep ONLY highest version (deduplicate stutters) This preserves the correct rendering where streaming fragments appear as separate consecutive message divs in the HTML output, while removing true duplicates from version upgrades. **Implementation:** - Uses packaging.version.parse for semantic version comparison - Handles both assistant messages (message.id) and tool results (tool_use_id) - Preserves messages without unique IDs (e.g., queue-operation) Tests verify both version deduplication and streaming fragment preservation. --- claude_code_log/renderer.py | 80 +++++++++ test/test_version_deduplication.py | 268 +++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 test/test_version_deduplication.py diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 32bbf57a..2d2b9708 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -2209,6 +2209,86 @@ def generate_html( if not title: title = "Claude Transcript" + # Deduplicate messages caused by Claude Code version upgrade during session + # Only deduplicate when same message.id appears with DIFFERENT versions + # Streaming fragments (same message.id, same version) are kept as separate messages + from claude_code_log.models import AssistantTranscriptEntry, UserTranscriptEntry + from packaging.version import parse as parse_version + from collections import defaultdict + + # Group messages by their unique identifier + message_groups: Dict[str, List[tuple[int, str, TranscriptEntry]]] = defaultdict( + list + ) + + for idx, message in enumerate(messages): + unique_id = None + version_str = getattr(message, "version", "0.0.0") + + # Determine unique identifier based on message type + if isinstance(message, AssistantTranscriptEntry): + # Assistant messages: use message.id + if hasattr(message.message, "id"): + unique_id = f"msg:{message.message.id}" # type: ignore + + elif isinstance(message, UserTranscriptEntry): + # User messages (tool results): use tool_use_id + if hasattr(message, "message") and message.message.content: + for item in message.message.content: + if hasattr(item, "tool_use_id"): + unique_id = f"tool:{item.tool_use_id}" + break + + if unique_id: + message_groups[unique_id].append((idx, version_str, message)) + + # Determine which indices to keep + indices_to_keep = set() + + for unique_id, group in message_groups.items(): + if len(group) == 1: + # Single message, always keep + indices_to_keep.add(group[0][0]) + else: + # Multiple messages with same ID - check if they have different versions + versions = {version_str for _, version_str, _ in group} + + if len(versions) == 1: + # All same version = streaming fragments, keep ALL of them + for idx, _, _ in group: + indices_to_keep.add(idx) + else: + # Different versions = version duplicates, keep only highest version + try: + # Sort by semantic version, keep highest + sorted_group = sorted( + group, key=lambda x: parse_version(x[1]), reverse=True + ) + indices_to_keep.add(sorted_group[0][0]) + except Exception: + # If version parsing fails, keep first occurrence + indices_to_keep.add(group[0][0]) + + # Build deduplicated list + deduplicated_messages: List[TranscriptEntry] = [] + + for idx, message in enumerate(messages): + # Check if this message has a unique ID + has_unique_id = False + if isinstance(message, AssistantTranscriptEntry): + has_unique_id = hasattr(message.message, "id") + elif isinstance(message, UserTranscriptEntry): + if hasattr(message, "message") and message.message.content: + has_unique_id = any( + hasattr(item, "tool_use_id") for item in message.message.content + ) + + # Keep message if: no unique ID (e.g., queue-operation) OR in keep set + if not has_unique_id or idx in indices_to_keep: + deduplicated_messages.append(message) + + messages = deduplicated_messages + # Pre-process to find and attach session summaries session_summaries: Dict[str, str] = {} uuid_to_session: Dict[str, str] = {} diff --git a/test/test_version_deduplication.py b/test/test_version_deduplication.py new file mode 100644 index 00000000..a19cec3a --- /dev/null +++ b/test/test_version_deduplication.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Tests for version-based deduplication during Claude Code upgrades.""" + +from datetime import datetime +from claude_code_log.models import ( + AssistantTranscriptEntry, + AssistantMessage, + UserTranscriptEntry, + UserMessage, + ToolUseContent, + ToolResultContent, +) +from claude_code_log.renderer import generate_html + + +class TestVersionDeduplication: + """Test that duplicate messages from version upgrades are deduplicated.""" + + def test_assistant_message_deduplication(self): + """Test deduplication of assistant messages by version.""" + timestamp = datetime.now().isoformat() + + # Same assistant message in two different Claude Code versions + msg_v1 = AssistantTranscriptEntry( + type="assistant", + uuid="uuid-v1", + parentUuid="parent-001", + timestamp=timestamp, + version="2.0.31", # Older version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_duplicate", + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_edit", + name="Edit", + input={ + "file_path": "/test/file.py", + "old_string": "old", + "new_string": "new", + }, + ), + ], + stop_reason="tool_use", + ), + ) + + msg_v2 = AssistantTranscriptEntry( + type="assistant", + uuid="uuid-v2", + parentUuid="parent-002", + timestamp=timestamp, + version="2.0.34", # Newer version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_duplicate", # SAME message.id + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_edit", + name="Edit", + input={ + "file_path": "/test/file.py", + "old_string": "old", + "new_string": "new", + }, + ), + ], + stop_reason="tool_use", + ), + ) + + # Test both orderings + for messages in [[msg_v1, msg_v2], [msg_v2, msg_v1]]: + html = generate_html(messages, "Version Test") + + # Should appear only once + tool_summary_count = html.count( + "/test/file.py" + ) + assert tool_summary_count == 1, ( + f"Expected 1 tool summary, got {tool_summary_count}" + ) + + def test_tool_result_deduplication(self): + """Test deduplication of tool results by version.""" + timestamp = datetime.now().isoformat() + + # Same tool result in two different Claude Code versions + result_v1 = UserTranscriptEntry( + type="user", + uuid="uuid-result-v1", + parentUuid="parent-001", + timestamp=timestamp, + version="2.0.31", # Older version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_read_test", + content="File contents here", + ) + ], + ), + ) + + result_v2 = UserTranscriptEntry( + type="user", + uuid="uuid-result-v2", + parentUuid="parent-002", + timestamp=timestamp, + version="2.0.34", # Newer version + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_read_test", # SAME tool_use_id + content="File contents here", + ) + ], + ), + ) + + # Test both orderings + for messages in [[result_v1, result_v2], [result_v2, result_v1]]: + html = generate_html(messages, "Tool Result Test") + + # Should appear only once + content_count = html.count("File contents here") + assert content_count == 1, f"Expected 1 tool result, got {content_count}" + + def test_full_stutter_pair(self): + """Test complete assistant+tool_result pair deduplication.""" + timestamp = datetime.now().isoformat() + + # Version 2.0.31 pair + assist_v1 = AssistantTranscriptEntry( + type="assistant", + uuid="assist-v1", + parentUuid="parent-001", + timestamp=timestamp, + version="2.0.31", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_full_test", + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_full_test", + name="Read", + input={"file_path": "/test/data.txt"}, + ), + ], + stop_reason="tool_use", + ), + ) + + result_v1 = UserTranscriptEntry( + type="user", + uuid="result-v1", + parentUuid="assist-v1", + timestamp=timestamp, + version="2.0.31", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_full_test", + content="Data content", + ) + ], + ), + ) + + # Version 2.0.34 pair (same IDs) + assist_v2 = AssistantTranscriptEntry( + type="assistant", + uuid="assist-v2", + parentUuid="parent-002", + timestamp=timestamp, + version="2.0.34", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=AssistantMessage( + id="msg_full_test", # SAME + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[ + ToolUseContent( + type="tool_use", + id="toolu_full_test", # SAME + name="Read", + input={"file_path": "/test/data.txt"}, + ), + ], + stop_reason="tool_use", + ), + ) + + result_v2 = UserTranscriptEntry( + type="user", + uuid="result-v2", + parentUuid="assist-v2", + timestamp=timestamp, + version="2.0.34", + isSidechain=False, + userType="external", + cwd="/test", + sessionId="session-test", + message=UserMessage( + role="user", + content=[ + ToolResultContent( + type="tool_result", + tool_use_id="toolu_full_test", # SAME + content="Data content", + ) + ], + ), + ) + + # Combine: v1 pair, then v2 pair + messages = [assist_v1, result_v1, assist_v2, result_v2] + html = generate_html(messages, "Full Pair Test") + + # Each should appear only once + file_path_count = html.count("/test/data.txt") + assert file_path_count == 1, f"Expected 1 file path, got {file_path_count}" + + content_count = html.count("Data content") + assert content_count == 1, f"Expected 1 data content, got {content_count}" From 4cd976f0b07ca3a24b3e6bfe63ae2b83db89c4ce Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 8 Nov 2025 00:52:54 +0100 Subject: [PATCH 34/35] Set font-size for Pygmentized text in Write to be same as in Edit, Read. --- claude_code_log/templates/components/message_styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude_code_log/templates/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index 2b4dc4ea..e0bfd672 100644 --- a/claude_code_log/templates/components/message_styles.css +++ b/claude_code_log/templates/components/message_styles.css @@ -231,7 +231,7 @@ background-color: var(--error-semi); } -.message.tool_result pre { +.message.tool_use pre, .message.tool_result pre { font-size: 80%; } From 49e2099cac2e8766fa5eee17ff4baf1c952a3b0b Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Sat, 8 Nov 2025 01:17:17 +0100 Subject: [PATCH 35/35] Fix: Add type annotations for pyright compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added type ignore comment and explicit type annotation to resolve pyright errors in version deduplication logic: - tool_use_id access now has type ignore comment - indices_to_keep explicitly typed as set[int] πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- claude_code_log/renderer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2d2b9708..26feecfb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -17,7 +17,6 @@ from pygments.util import ClassNotFound # type: ignore[reportUnknownVariableType] from .models import ( - AssistantTranscriptEntry, TranscriptEntry, SummaryTranscriptEntry, SystemTranscriptEntry, @@ -2236,14 +2235,14 @@ def generate_html( if hasattr(message, "message") and message.message.content: for item in message.message.content: if hasattr(item, "tool_use_id"): - unique_id = f"tool:{item.tool_use_id}" + unique_id = f"tool:{item.tool_use_id}" # type: ignore break if unique_id: message_groups[unique_id].append((idx, version_str, message)) # Determine which indices to keep - indices_to_keep = set() + indices_to_keep: set[int] = set() for unique_id, group in message_groups.items(): if len(group) == 1: