diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 93ef105b..26feecfb 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -11,9 +11,12 @@ import html import mistune from jinja2 import Environment, FileSystemLoader, select_autoescape +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, TranscriptEntry, SummaryTranscriptEntry, SystemTranscriptEntry, @@ -37,6 +40,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: @@ -175,8 +205,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 # 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.""" + 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) # type: ignore[reportUnknownVariableType] + except ClassNotFound: + lexer = TextLexer() # type: ignore[reportUnknownVariableType] + + formatter = HtmlFormatter( # type: ignore[reportUnknownVariableType] + linenos=False, # No line numbers in markdown code blocks + cssclass="highlight", + wrapcode=True, + ) + return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType] + 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=[ @@ -186,6 +252,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) @@ -290,19 +357,103 @@ def format_todowrite_content(tool_use: ToolUseContent) -> str: """ +def _highlight_code_with_pygments( + code: str, file_path: str, show_linenos: bool = True, linenostart: int = 1 +) -> 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) + linenostart: Starting line number for display (default: 1) + + Returns: + HTML string with syntax-highlighted code + """ + try: + # Try to get lexer based on filename + lexer = get_lexer_for_filename(file_path, code) # type: ignore[reportUnknownVariableType] + except ClassNotFound: + # Fall back to plain text lexer + lexer = TextLexer() # type: ignore[reportUnknownVariableType] + + # Create formatter with line numbers in table format + formatter = HtmlFormatter( # type: ignore[reportUnknownVariableType] + linenos="table" if show_linenos else False, + cssclass="highlight", + wrapcode=True, + linenostart=linenostart, + ) + + # Highlight the code + return str(highlight(code, lexer, formatter)) # type: ignore[reportUnknownArgumentType] + + +def format_read_tool_content(tool_use: ToolUseContent) -> str: # noqa: ARG001 + """Format Read tool use content showing 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 "" + + +def format_write_tool_content(tool_use: ToolUseContent) -> str: + """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", "") + + html_parts = ["
{escaped_command}")
@@ -373,26 +524,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)
-
- escaped_path = escape_html(file_path)
-
- html_parts = ["{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:
+ parsed_result = _parse_read_tool_result(raw_content)
+ if parsed_result:
+ code_content, system_reminder, line_offset = parsed_result
+
+ # 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 = ["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 @@ -2056,6 +2756,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) @@ -2115,6 +2816,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") diff --git a/claude_code_log/templates/components/global_styles.css b/claude_code_log/templates/components/global_styles.css index 77442f46..5ee38644 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; @@ -96,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/components/message_styles.css b/claude_code_log/templates/components/message_styles.css index 8bd2502c..e0bfd672 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; @@ -178,8 +204,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); @@ -205,7 +231,7 @@ background-color: var(--error-semi); } -.message.tool_result pre { +.message.tool_use pre, .message.tool_result pre { font-size: 80%; } @@ -280,7 +306,7 @@ padding: 8px 12px; margin: 8px 0; border-radius: 4px; - font-size: 0.9em; + font-size: 80%; font-style: italic; } @@ -339,10 +365,6 @@ pre > code { display: block; } -code { - background-color: var(--code-bg-color); -} - /* Tool content styling */ .tool-content { background-color: var(--neutral-dimmed); @@ -476,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 new file mode 100644 index 00000000..eb1fb407 --- /dev/null +++ b/claude_code_log/templates/components/pygments_styles.css @@ -0,0 +1,218 @@ +/* Pygments syntax highlighting styles */ + +/* Base styles for highlighted code blocks */ +.highlight { + background: var(--color-bg-secondary); + border-radius: 4px; + overflow-x: auto; +} + +.highlight pre { + margin: 0; + 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.) */ +.content.markdown .highlight pre code { + font-size: 80%; +} + +/* Line numbers table styling */ +.highlight .highlighttable { + width: 100%; + border-spacing: 0; + background: transparent; +} + +.highlight .highlighttable td { + padding: 0; + vertical-align: top; +} + +.highlight .highlighttable td.linenos { + width: 1%; + text-align: right; + user-select: none; + border-right: 1px solid var(--color-border-dim); +} + +.highlight .linenos pre { + color: var(--color-text-dim); + background: var(--color-bg-tertiary); + padding: 0.65em 0.5em 0.5em 0.8em; + margin: 0; + line-height: 1.5; + white-space: pre; +} + +.highlight .linenos .normal { + color: inherit; +} + +.highlight td.code { + width: 99%; +} + +.highlight td.code pre { + padding-left: 0.8em; + line-height: 1.5; +} + +/* 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; +} + +/* 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; +} + +.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; + font-size: 80%; + color: var(--color-text-secondary); +} + +/* Edit tool result specific styles */ +.edit-tool-result { + 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; +} + +.write-file-path { + font-weight: 600; + color: var(--color-green); + margin-bottom: 0.5em; + font-family: var(--font-monospace); + font-size: 0.9em; + padding-left: 1.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/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 2bd92d33..31186794 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' %} @@ -80,20 +81,27 @@