From 3973f14166880bfaf47eab00b0c5f38d2dd19826 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Mon, 29 Dec 2025 01:25:57 +0100 Subject: [PATCH 01/22] Add minimal docstrings to MarkdownRenderer methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds concise docstrings showing typical output format to satisfy docstring coverage requirements (50 methods documented). ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/markdown/renderer.py | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index c1589903..ebf14402 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -250,6 +250,7 @@ def _get_message_text(self, msg: TemplateMessage) -> str: # ------------------------------------------------------------------------- def format_SystemMessage(self, content: SystemMessage, _: TemplateMessage) -> str: + """Format โ†’ 'โ„น๏ธ message text'.""" level_prefix = {"info": "โ„น๏ธ", "warning": "โš ๏ธ", "error": "โŒ"}.get( content.level, "" ) @@ -258,6 +259,7 @@ def format_SystemMessage(self, content: SystemMessage, _: TemplateMessage) -> st def format_HookSummaryMessage( self, content: HookSummaryMessage, _: TemplateMessage ) -> str: + """Format โ†’ 'Hook produced output\\nโŒ Error: ...'.""" parts: list[str] = [] if content.has_output: parts.append("Hook produced output") @@ -272,6 +274,7 @@ def format_HookSummaryMessage( def format_SessionHeaderMessage( self, content: SessionHeaderMessage, _: TemplateMessage ) -> str: + """Format โ†’ ''.""" # Return just the anchor - it will be placed before the heading session_short = content.session_id[:8] return f'' @@ -279,6 +282,7 @@ def format_SessionHeaderMessage( def title_SessionHeaderMessage( self, content: SessionHeaderMessage, _: TemplateMessage ) -> str: + """Title โ†’ '๐Ÿ“‹ Session `abc12345`: summary'.""" # Return the title with session ID and optional summary session_short = content.session_id[:8] if content.summary: @@ -292,6 +296,7 @@ def title_SessionHeaderMessage( def format_UserTextMessage( self, content: UserTextMessage, _: TemplateMessage ) -> str: + """Format โ†’ fenced code block(s) with user text.""" parts: list[str] = [] for item in content.items: if isinstance(item, ImageContent): @@ -305,6 +310,7 @@ def format_UserTextMessage( def title_UserTextMessage( self, _content: UserTextMessage, _message: TemplateMessage ) -> str: + """Title โ†’ '๐Ÿคท User: *excerpt...*'.""" if excerpt := self._excerpt(self._get_message_text(_message)): return f"๐Ÿคท User: *{self._escape_stars(excerpt)}*" return "๐Ÿคท User" @@ -312,12 +318,14 @@ def title_UserTextMessage( def format_UserSlashCommandMessage( self, content: UserSlashCommandMessage, _: TemplateMessage ) -> str: + """Format โ†’ blockquoted text.""" # UserSlashCommandMessage has a text attribute (markdown), quote to protect it return self._quote(content.text) if content.text.strip() else "" def format_SlashCommandMessage( self, content: SlashCommandMessage, _: TemplateMessage ) -> str: + """Format โ†’ '**Args:** `args`' + fenced contents.""" parts: list[str] = [] # Command name is in the title, only include args and contents here if content.command_args: @@ -329,12 +337,14 @@ def format_SlashCommandMessage( def title_SlashCommandMessage( self, content: SlashCommandMessage, _message: TemplateMessage ) -> str: + """Title โ†’ '๐Ÿคท Command `/cmd`'.""" # command_name already includes the leading slash return f"๐Ÿคท Command `{content.command_name}`" def format_CommandOutputMessage( self, content: CommandOutputMessage, _: TemplateMessage ) -> str: + """Format โ†’ blockquote (markdown) or fenced code block.""" if content.is_markdown: # Quote markdown output to protect it return self._quote(content.stdout) @@ -343,11 +353,13 @@ def format_CommandOutputMessage( def format_BashInputMessage( self, content: BashInputMessage, _: TemplateMessage ) -> str: + """Format โ†’ '```bash\\n$ command\\n```'.""" return self._code_fence(f"$ {content.command}", "bash") def format_BashOutputMessage( self, content: BashOutputMessage, _: TemplateMessage ) -> str: + """Format โ†’ fenced code block (ANSI stripped).""" # Combine stdout and stderr, strip ANSI codes for markdown output parts: list[str] = [] if content.stdout: @@ -361,12 +373,14 @@ def format_BashOutputMessage( def format_CompactedSummaryMessage( self, content: CompactedSummaryMessage, _: TemplateMessage ) -> str: + """Format โ†’ blockquoted summary.""" # Quote to protect embedded markdown return self._quote(content.summary_text) def format_UserMemoryMessage( self, content: UserMemoryMessage, _: TemplateMessage ) -> str: + """Format โ†’ fenced code block.""" return self._code_fence(content.memory_text) # ------------------------------------------------------------------------- @@ -376,6 +390,7 @@ def format_UserMemoryMessage( def format_AssistantTextMessage( self, content: AssistantTextMessage, _: TemplateMessage ) -> str: + """Format โ†’ blockquoted text.""" parts: list[str] = [] for item in content.items: if isinstance(item, ImageContent): @@ -389,10 +404,12 @@ def format_AssistantTextMessage( def format_ThinkingMessage( self, content: ThinkingMessage, _: TemplateMessage ) -> str: + """Format โ†’
Thinking...blockquote
.""" quoted = self._quote(content.thinking) return self._collapsible("Thinking...", quoted) def format_UnknownMessage(self, content: UnknownMessage, _: TemplateMessage) -> str: + """Format โ†’ '*Unknown content type: ...*'.""" return f"*Unknown content type: {content.type_name}*" # ------------------------------------------------------------------------- @@ -400,10 +417,12 @@ def format_UnknownMessage(self, content: UnknownMessage, _: TemplateMessage) -> # ------------------------------------------------------------------------- def format_BashInput(self, input: BashInput, _: TemplateMessage) -> str: + """Format โ†’ '```bash\\n$ command\\n```'.""" # Description is in the title, just show the command with $ prefix return self._code_fence(f"$ {input.command}", "bash") def format_ReadInput(self, input: ReadInput, _: TemplateMessage) -> str: + """Format โ†’ '*(lines Nโ€“M)*' or empty.""" # File path goes in the collapsible summary of ReadOutput # Just show line range hint here if applicable if input.offset or input.limit: @@ -413,16 +432,19 @@ def format_ReadInput(self, input: ReadInput, _: TemplateMessage) -> str: return "" def format_WriteInput(self, input: WriteInput, _: TemplateMessage) -> str: + """Format โ†’ collapsible with file path + fenced content.""" summary = f"{input.file_path}" content = self._code_fence(input.content, self._lang_from_path(input.file_path)) return self._collapsible(summary, content) def format_EditInput(self, input: EditInput, _: TemplateMessage) -> str: + """Format โ†’ '```diff\\n...\\n```'.""" # Diff is visible; result goes in collapsible in format_EditOutput diff_text = generate_unified_diff(input.old_string, input.new_string) return self._code_fence(diff_text, "diff") def format_MultiEditInput(self, input: MultiEditInput, _: TemplateMessage) -> str: + """Format โ†’ multiple '**Edit N:**' + diff blocks.""" # All diffs visible; result goes in collapsible in format_EditOutput parts: list[str] = [] for i, edit in enumerate(input.edits, 1): @@ -432,16 +454,19 @@ def format_MultiEditInput(self, input: MultiEditInput, _: TemplateMessage) -> st return "\n\n".join(parts) def format_GlobInput(self, _input: GlobInput, _: TemplateMessage) -> str: + """Format โ†’ '' (pattern in title).""" # Pattern and path are in the title return "" def format_GrepInput(self, input: GrepInput, _: TemplateMessage) -> str: + """Format โ†’ 'Glob: `pattern`' or empty.""" # Pattern and path are in the title, only show glob filter if present if input.glob: return f"Glob: `{input.glob}`" return "" def format_TaskInput(self, input: TaskInput, _: TemplateMessage) -> str: + """Format โ†’ collapsible 'Instructions' with prompt.""" # Description is now in the title, just show prompt as collapsible return ( self._collapsible("Instructions", self._quote(input.prompt)) @@ -450,6 +475,7 @@ def format_TaskInput(self, input: TaskInput, _: TemplateMessage) -> str: ) def format_TodoWriteInput(self, input: TodoWriteInput, _: TemplateMessage) -> str: + """Format โ†’ '- โฌœ task1\\n- โœ… task2'.""" parts: list[str] = [] for todo in input.todos: status_icon = {"pending": "โฌœ", "in_progress": "๐Ÿ”„", "completed": "โœ…"}.get( @@ -461,12 +487,14 @@ def format_TodoWriteInput(self, input: TodoWriteInput, _: TemplateMessage) -> st def format_AskUserQuestionInput( self, _input: AskUserQuestionInput, _: TemplateMessage ) -> str: + """Format โ†’ '' (rendered with output).""" # Input is rendered together with output in format_AskUserQuestionOutput return "" def format_ExitPlanModeInput( self, _input: ExitPlanModeInput, _: TemplateMessage ) -> str: + """Format โ†’ '' (title only).""" # Title contains "Exiting plan mode", body is empty return "" @@ -500,21 +528,25 @@ def _render_params(self, params: dict[str, Any]) -> str: # ------------------------------------------------------------------------- def format_ReadOutput(self, output: ReadOutput, _: TemplateMessage) -> str: + """Format โ†’ collapsible with file path + syntax-highlighted content.""" summary = f"{output.file_path}" if output.file_path else "Content" lang = self._lang_from_path(output.file_path or "") content = self._code_fence(output.content, lang) return self._collapsible(summary, content) def format_WriteOutput(self, output: WriteOutput, _: TemplateMessage) -> str: + """Format โ†’ 'โœ“ Wrote N bytes'.""" return f"โœ“ {output.message}" def format_EditOutput(self, output: EditOutput, _: TemplateMessage) -> str: + """Format โ†’ collapsible with result or 'โœ“ Edited'.""" if msg := output.message: content = self._code_fence(msg, self._lang_from_path(output.file_path)) return self._collapsible(f"{output.file_path}", content) return "โœ“ Edited" def format_BashOutput(self, output: BashOutput, _: TemplateMessage) -> str: + """Format โ†’ fenced code block (ANSI stripped, diff detected).""" # Strip ANSI codes for markdown output text = re.sub(r"\x1b\[[0-9;]*m", "", output.content) # Detect git diff output @@ -522,6 +554,7 @@ def format_BashOutput(self, output: BashOutput, _: TemplateMessage) -> str: return self._code_fence(text, lang) def format_GlobOutput(self, output: GlobOutput, _: TemplateMessage) -> str: + """Format โ†’ '- `file1`\\n- `file2`' or '*No files found*'.""" if not output.files: return "*No files found*" return "\n".join(f"- `{f}`" for f in output.files) @@ -563,12 +596,14 @@ def format_AskUserQuestionOutput( return "\n\n".join(parts).rstrip() def format_TaskOutput(self, output: TaskOutput, _: TemplateMessage) -> str: + """Format โ†’ collapsible 'Report' with blockquoted result.""" # TaskOutput contains markdown, wrap in collapsible Report return self._collapsible("Report", self._quote(output.result)) def format_ExitPlanModeOutput( self, output: ExitPlanModeOutput, _: TemplateMessage ) -> str: + """Format โ†’ 'โœ“ Approved' or 'โœ— Not approved'.""" status = "โœ“ Approved" if output.approved else "โœ— Not approved" if output.message: return f"{status}\n\n{output.message}" @@ -595,52 +630,64 @@ def format_ToolResultContent( # ------------------------------------------------------------------------- def title_BashInput(self, input: BashInput, _: TemplateMessage) -> str: + """Title โ†’ '๐Ÿ’ป Bash: *description*'.""" if desc := input.description: return f"๐Ÿ’ป Bash: *{self._escape_stars(desc)}*" return "๐Ÿ’ป Bash" def title_ReadInput(self, input: ReadInput, _: TemplateMessage) -> str: + """Title โ†’ '๐Ÿ‘€ Read `filename`'.""" return f"๐Ÿ‘€ Read `{Path(input.file_path).name}`" def title_WriteInput(self, input: WriteInput, _: TemplateMessage) -> str: + """Title โ†’ 'โœ๏ธ Write `filename`'.""" return f"โœ๏ธ Write `{Path(input.file_path).name}`" def title_EditInput(self, input: EditInput, _: TemplateMessage) -> str: + """Title โ†’ 'โœ๏ธ Edit `filename`'.""" return f"โœ๏ธ Edit `{Path(input.file_path).name}`" def title_MultiEditInput(self, input: MultiEditInput, _: TemplateMessage) -> str: + """Title โ†’ 'โœ๏ธ MultiEdit `filename`'.""" return f"โœ๏ธ MultiEdit `{Path(input.file_path).name}`" def title_GlobInput(self, input: GlobInput, _: TemplateMessage) -> str: + """Title โ†’ '๐Ÿ“‚ Glob `pattern`[ in `path`]'.""" title = f"๐Ÿ“‚ Glob `{input.pattern}`" return f"{title} in `{input.path}`" if input.path else title def title_GrepInput(self, input: GrepInput, _: TemplateMessage) -> str: + """Title โ†’ '๐Ÿ”Ž Grep `pattern`[ in `path`]'.""" base = f"๐Ÿ”Ž Grep `{input.pattern}`" return f"{base} in `{input.path}`" if input.path else base def title_TaskInput(self, input: TaskInput, _: TemplateMessage) -> str: + """Title โ†’ '๐Ÿค– Task (subagent): *description*'.""" subagent = f" ({input.subagent_type})" if input.subagent_type else "" if desc := input.description: return f"๐Ÿค– Task{subagent}: *{self._escape_stars(desc)}*" return f"๐Ÿค– Task{subagent}" def title_TodoWriteInput(self, _input: TodoWriteInput, _: TemplateMessage) -> str: + """Title โ†’ 'โœ… Todo List'.""" return "โœ… Todo List" def title_AskUserQuestionInput( self, _input: AskUserQuestionInput, _: TemplateMessage ) -> str: + """Title โ†’ 'โ“ Asking questions...'.""" return "โ“ Asking questions..." def title_ExitPlanModeInput( self, _input: ExitPlanModeInput, _: TemplateMessage ) -> str: + """Title โ†’ '๐Ÿ“ Exiting plan mode'.""" return "๐Ÿ“ Exiting plan mode" def title_ThinkingMessage( self, _content: ThinkingMessage, _message: TemplateMessage ) -> str: + """Title โ†’ '๐Ÿค– Assistant: *excerpt*' (paired) or '๐Ÿ’ญ Thinking: *excerpt*'.""" # When paired with Assistant, use Assistant title with assistant excerpt if _message.is_first_in_pair and _message.pair_last is not None: if ( @@ -658,6 +705,7 @@ def title_ThinkingMessage( def title_AssistantTextMessage( self, _content: AssistantTextMessage, message: TemplateMessage ) -> str: + """Title โ†’ '๐Ÿค– Assistant: *excerpt*' or '' (if paired).""" # When paired (after Thinking), skip title (already rendered with Thinking) if message.is_last_in_pair: return "" From 9159e3ba07b72dcaa31c44d2e33e79c8b9bdeaa1 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 01:08:33 +0100 Subject: [PATCH 02/22] Fix session file count for Markdown output format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI success message was hard-coded to count session-*.html files, which always reported 0 session files when using --output-format md. Now derives extension from output_format to count the correct files. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index c325efcf..23520899 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -646,7 +646,8 @@ def main( else: jsonl_count = len(list(input_path.glob("*.jsonl"))) if not no_individual_sessions: - session_files = list(input_path.glob("session-*.html")) + ext = "md" if output_format in ("md", "markdown") else "html" + session_files = list(input_path.glob(f"session-*.{ext}")) click.echo( f"Successfully combined {jsonl_count} transcript files from {input_path} to {output_path} and generated {len(session_files)} individual session files" ) From a89bfe69419317a145ff5e8e983c9f7bf7f8fb49 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 01:15:15 +0100 Subject: [PATCH 03/22] Fix timing lambdas to avoid UnboundLocalError on exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initialize ctx and session_nav before their respective with blocks so timing lambdas can safely access them even if an exception occurs. Previously, if _render_messages raised, the lambda would try to access ctx before assignment, masking the original exception. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/renderer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index d137ee3b..78e9c5f7 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -568,10 +568,14 @@ def generate_template_messages( ) # Pass 2: Render messages to TemplateMessage objects - with log_timing(lambda: f"Render messages ({len(ctx.messages)} messages)", t_start): + ctx: RenderingContext | None = None + with log_timing( + lambda: f"Render messages ({len(ctx.messages) if ctx else 0} messages)", t_start + ): ctx = _render_messages(filtered_messages, sessions, show_tokens_for_message) # Prepare session navigation data (uses ctx for session header indices) + session_nav: list[dict[str, Any]] = [] with log_timing( lambda: f"Session navigation building ({len(session_nav)} sessions)", t_start ): From 31d5ab3909f0d003c975b7cdcf818c1ef9a8ad89 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 01:18:35 +0100 Subject: [PATCH 04/22] Add error handling for image export file operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap referenced mode file operations in try/except to handle: - PermissionError from mkdir or write_bytes - OSError from disk full conditions - binascii.Error/ValueError from malformed base64 data Returns None on failure to trigger placeholder rendering, maintaining graceful degradation. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/image_export.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/claude_code_log/image_export.py b/claude_code_log/image_export.py index 98c21d10..1113ac9a 100644 --- a/claude_code_log/image_export.py +++ b/claude_code_log/image_export.py @@ -5,6 +5,7 @@ """ import base64 +import binascii from pathlib import Path from typing import TYPE_CHECKING @@ -45,20 +46,25 @@ def export_image( if output_dir is None: return None - # Create images subdirectory - images_dir = output_dir / "images" - images_dir.mkdir(exist_ok=True) + try: + # Create images subdirectory + images_dir = output_dir / "images" + images_dir.mkdir(exist_ok=True) - # Generate filename based on media type - ext = _get_extension(image.source.media_type) - filename = f"image_{counter:04d}{ext}" - filepath = images_dir / filename + # Generate filename based on media type + ext = _get_extension(image.source.media_type) + filename = f"image_{counter:04d}{ext}" + filepath = images_dir / filename - # Decode and write image - image_data = base64.b64decode(image.source.data) - filepath.write_bytes(image_data) + # Decode and write image + image_data = base64.b64decode(image.source.data) + filepath.write_bytes(image_data) - return f"images/{filename}" + return f"images/{filename}" + except (OSError, binascii.Error, ValueError): + # Graceful degradation: return None to trigger placeholder rendering + # Covers: PermissionError (mkdir/write), disk full, malformed base64 + return None # Unsupported mode return None From cf9db4d1a639c4b5c227bd53ca1e7065ad24fb33 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 01:21:23 +0100 Subject: [PATCH 05/22] Update style guide script docstring to mention Markdown output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- scripts/generate_style_guide.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/generate_style_guide.py b/scripts/generate_style_guide.py index ed49415f..563a27f8 100755 --- a/scripts/generate_style_guide.py +++ b/scripts/generate_style_guide.py @@ -2,9 +2,9 @@ """ Generate a visual style guide showing all message types and rendering styles. -This script creates a comprehensive HTML file that demonstrates how different -types of Claude transcript messages are rendered, serving both as a test -and as documentation for the visual design. +This script creates comprehensive HTML and Markdown files that demonstrate how +different types of Claude transcript messages are rendered, serving both as a +test and as documentation for the visual design. Session 1: Hand-crafted examples showing various formatting scenarios Session 2: Auto-generated from dev-docs/messages samples showing all message types From 54c6bd8cdbfccd0cdb4c99f2cf9272b0811c2544 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 01:29:45 +0100 Subject: [PATCH 06/22] Make CLI --clear-output format-aware with --clear-html backward compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename _clear_html_files to _clear_output_files with file_ext parameter - Add --clear-output as preferred option name (--clear-html still works) - Clear .md files when --format=md, .html files otherwise - Update help strings to be format-agnostic ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/cli.py | 86 ++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 23520899..461280e4 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -290,12 +290,13 @@ def _clear_caches(input_path: Path, all_projects: bool) -> None: click.echo(f"Warning: Failed to clear cache: {e}") -def _clear_html_files(input_path: Path, all_projects: bool) -> None: - """Clear HTML files for the specified path.""" +def _clear_output_files(input_path: Path, all_projects: bool, file_ext: str) -> None: + """Clear generated output files (HTML or Markdown) for the specified path.""" + ext_upper = file_ext.upper() try: if all_projects: - # Clear HTML files for all project directories - click.echo("Clearing HTML files for all projects...") + # Clear output files for all project directories + click.echo(f"Clearing {ext_upper} files for all projects...") project_dirs = [ d for d in input_path.iterdir() @@ -305,55 +306,55 @@ def _clear_html_files(input_path: Path, all_projects: bool) -> None: total_removed = 0 for project_dir in project_dirs: try: - # Remove HTML files in project directory - html_files = list(project_dir.glob("*.html")) - for html_file in html_files: - html_file.unlink() + # Remove output files in project directory + output_files = list(project_dir.glob(f"*.{file_ext}")) + for output_file in output_files: + output_file.unlink() total_removed += 1 - if html_files: + if output_files: click.echo( - f" Removed {len(html_files)} HTML files from {project_dir.name}" + f" Removed {len(output_files)} {ext_upper} files from {project_dir.name}" ) except Exception as e: click.echo( - f" Warning: Failed to clear HTML files for {project_dir.name}: {e}" + f" Warning: Failed to clear {ext_upper} files for {project_dir.name}: {e}" ) - # Also remove top-level index.html - index_file = input_path / "index.html" + # Also remove top-level index file + index_file = input_path / f"index.{file_ext}" if index_file.exists(): index_file.unlink() total_removed += 1 - click.echo(" Removed top-level index.html") + click.echo(f" Removed top-level index.{file_ext}") if total_removed > 0: - click.echo(f"Total: Removed {total_removed} HTML files") + click.echo(f"Total: Removed {total_removed} {ext_upper} files") else: - click.echo("No HTML files found to remove") + click.echo(f"No {ext_upper} files found to remove") elif input_path.is_dir(): - # Clear HTML files for single directory - click.echo(f"Clearing HTML files for {input_path}...") - html_files = list(input_path.glob("*.html")) - for html_file in html_files: - html_file.unlink() - - if html_files: - click.echo(f"Removed {len(html_files)} HTML files") + # Clear output files for single directory + click.echo(f"Clearing {ext_upper} files for {input_path}...") + output_files = list(input_path.glob(f"*.{file_ext}")) + for output_file in output_files: + output_file.unlink() + + if output_files: + click.echo(f"Removed {len(output_files)} {ext_upper} files") else: - click.echo("No HTML files found to remove") + click.echo(f"No {ext_upper} files found to remove") else: - # Single file - remove corresponding HTML file - html_file = input_path.with_suffix(".html") - if html_file.exists(): - html_file.unlink() - click.echo(f"Removed {html_file}") + # Single file - remove corresponding output file + output_file = input_path.with_suffix(f".{file_ext}") + if output_file.exists(): + output_file.unlink() + click.echo(f"Removed {output_file}") else: - click.echo("No corresponding HTML file found to remove") + click.echo(f"No corresponding {ext_upper} file found to remove") except Exception as e: - click.echo(f"Warning: Failed to clear HTML files: {e}") + click.echo(f"Warning: Failed to clear {ext_upper} files: {e}") @click.command() @@ -362,7 +363,7 @@ def _clear_html_files(input_path: Path, all_projects: bool) -> None: "-o", "--output", type=click.Path(path_type=Path), - help="Output HTML file path (default: input file with .html extension or combined_transcripts.html for directories)", + help="Output file path (default: input file with format extension, or combined_transcripts.{html,md} for directories)", ) @click.option( "--open-browser", @@ -400,9 +401,11 @@ def _clear_html_files(input_path: Path, all_projects: bool) -> None: help="Clear all cache directories before processing", ) @click.option( + "--clear-output", "--clear-html", + "clear_output", is_flag=True, - help="Clear all HTML files and force regeneration", + help="Clear generated output files (HTML or Markdown based on --format) and force regeneration", ) @click.option( "--tui", @@ -445,7 +448,7 @@ def main( no_individual_sessions: bool, no_cache: bool, clear_cache: bool, - clear_html: bool, + clear_output: bool, tui: bool, projects_dir: Optional[Path], output_format: str, @@ -567,12 +570,13 @@ def main( click.echo("Cache cleared successfully.") return - # Handle HTML files clearing - if clear_html: - _clear_html_files(input_path, all_projects) - if clear_html and not (from_date or to_date or input_path.is_file()): - # If only clearing HTML files, exit after clearing - click.echo("HTML files cleared successfully.") + # Handle output files clearing + if clear_output: + file_ext = "md" if output_format in ("md", "markdown") else "html" + _clear_output_files(input_path, all_projects, file_ext) + if clear_output and not (from_date or to_date or input_path.is_file()): + # If only clearing output files, exit after clearing + click.echo(f"{file_ext.upper()} files cleared successfully.") return # Handle --all-projects flag or default behavior From 6b82fda3700f9a893cab42477b37e75071851047 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 01:31:47 +0100 Subject: [PATCH 07/22] Add 'm' shortcut to TUI for opening Markdown session files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index f3a69b5b..a11af2ca 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -220,6 +220,7 @@ class SessionBrowser(App[Optional[str]]): BINDINGS: ClassVar[list[BindingType]] = [ Binding("q", "quit", "Quit"), Binding("h", "export_selected", "Open HTML page"), + Binding("m", "export_markdown", "Open Markdown"), Binding("c", "resume_selected", "Resume in Claude Code"), Binding("e", "toggle_expanded", "Toggle Expanded View"), Binding("p", "back_to_projects", "Open Project Selector"), @@ -524,6 +525,22 @@ def action_export_selected(self) -> None: except Exception as e: self.notify(f"Error opening session HTML: {e}", severity="error") + def action_export_markdown(self) -> None: + """Export the selected session to Markdown.""" + if not self.selected_session_id: + self.notify("No session selected", severity="warning") + return + + try: + # Use cached session Markdown file directly + session_file = self.project_path / f"session-{self.selected_session_id}.md" + + webbrowser.open(f"file://{session_file}") + self.notify(f"Opened session Markdown: {session_file}") + + except Exception as e: + self.notify(f"Error opening session Markdown: {e}", severity="error") + def action_resume_selected(self) -> None: """Resume the selected session in Claude Code.""" if not self.selected_session_id: @@ -646,7 +663,8 @@ def action_toggle_help(self) -> None: "- Expanded content updates automatically when visible\n\n" "Actions:\n" "- e: Toggle expanded view for session\n" - "- h: Open selected session's HTML page log\n" + "- h: Open selected session's HTML page\n" + "- m: Open selected session's Markdown file\n" "- c: Resume selected session in Claude Code\n" "- p: Open project selector\n" "- q: Quit\n\n" From 86a81de15d86a542a70001c6dda7f6f0fe436263 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 01:38:01 +0100 Subject: [PATCH 08/22] Add embedded Markdown viewer to TUI (press 'v') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New MarkdownViewerScreen modal with scrollable content - Uses Textual's built-in Markdown widget for rendering - Press ESC or 'q' to dismiss and return to session list - Reads session Markdown file and displays inline ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 90 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index a11af2ca..81821a9a 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -9,12 +9,14 @@ from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import Container, Vertical +from textual.containers import Container, Vertical, VerticalScroll +from textual.screen import ModalScreen from textual.widgets import ( DataTable, Footer, Header, Label, + Markdown, Static, ) from textual.reactive import reactive @@ -179,6 +181,65 @@ async def action_quit(self) -> None: self.exit(None) +class MarkdownViewerScreen(ModalScreen[None]): + """Modal screen for viewing Markdown content.""" + + CSS = """ + MarkdownViewerScreen { + align: center middle; + } + + #md-container { + width: 90%; + height: 90%; + border: solid $primary; + background: $surface; + } + + #md-header { + dock: top; + height: 3; + background: $primary; + color: $text; + text-align: center; + padding: 1; + } + + #md-content { + height: 1fr; + padding: 1 2; + } + + #md-footer { + dock: bottom; + height: 1; + background: $primary-darken-2; + color: $text-muted; + text-align: center; + } + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("escape", "dismiss", "Close", show=True), + Binding("q", "dismiss", "Close", show=False), + ] + + def __init__(self, content: str, title: str = "Markdown Viewer") -> None: + super().__init__() + self.md_content = content + self.md_title = title + + def compose(self) -> ComposeResult: + with Container(id="md-container"): + yield Static(self.md_title, id="md-header") + with VerticalScroll(id="md-content"): + yield Markdown(self.md_content) + yield Static("Press ESC or q to close", id="md-footer") + + def action_dismiss(self) -> None: + self.dismiss(None) + + class SessionBrowser(App[Optional[str]]): """Interactive TUI for browsing and managing Claude Code Log sessions.""" @@ -221,6 +282,7 @@ class SessionBrowser(App[Optional[str]]): Binding("q", "quit", "Quit"), Binding("h", "export_selected", "Open HTML page"), Binding("m", "export_markdown", "Open Markdown"), + Binding("v", "view_markdown", "View Markdown"), Binding("c", "resume_selected", "Resume in Claude Code"), Binding("e", "toggle_expanded", "Toggle Expanded View"), Binding("p", "back_to_projects", "Open Project Selector"), @@ -541,6 +603,29 @@ def action_export_markdown(self) -> None: except Exception as e: self.notify(f"Error opening session Markdown: {e}", severity="error") + def action_view_markdown(self) -> None: + """View the selected session's Markdown in an embedded viewer.""" + if not self.selected_session_id: + self.notify("No session selected", severity="warning") + return + + try: + session_file = self.project_path / f"session-{self.selected_session_id}.md" + + if not session_file.exists(): + self.notify( + f"Markdown file not found: {session_file.name}", + severity="warning", + ) + return + + content = session_file.read_text(encoding="utf-8") + title = f"Session: {self.selected_session_id[:8]}..." + self.push_screen(MarkdownViewerScreen(content, title)) + + except Exception as e: + self.notify(f"Error viewing Markdown: {e}", severity="error") + def action_resume_selected(self) -> None: """Resume the selected session in Claude Code.""" if not self.selected_session_id: @@ -664,7 +749,8 @@ def action_toggle_help(self) -> None: "Actions:\n" "- e: Toggle expanded view for session\n" "- h: Open selected session's HTML page\n" - "- m: Open selected session's Markdown file\n" + "- m: Open selected session's Markdown file (in browser)\n" + "- v: View Markdown in embedded viewer\n" "- c: Resume selected session in Claude Code\n" "- p: Open project selector\n" "- q: Quit\n\n" From 9b03632485aba863d17bd6e5192ab1d1afc71fd1 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 02:14:38 +0100 Subject: [PATCH 09/22] Add on-demand session file generation to TUI (h/m/v shortcuts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _ensure_session_file() helper that checks if file exists and is up-to-date, regenerating if needed (works for both HTML and Markdown) - Update action_export_selected() (h) to generate HTML on-demand - Update action_export_markdown() (m) to generate Markdown on-demand - Update action_view_markdown() (v) to generate Markdown on-demand - Use load_directory_transcripts and get_renderer() for generation ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 103 ++++++++++++++++++++++++++++++++++------- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index 81821a9a..f743ab63 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -22,7 +22,8 @@ from textual.reactive import reactive from .cache import CacheManager, SessionCacheData, get_library_version -from .converter import ensure_fresh_cache +from .converter import ensure_fresh_cache, load_directory_transcripts +from .renderer import get_renderer from .utils import get_project_display_name @@ -570,35 +571,39 @@ def _update_selected_session_from_cursor(self) -> None: pass def action_export_selected(self) -> None: - """Export the selected session to HTML.""" + """Export the selected session to HTML and open in browser.""" if not self.selected_session_id: self.notify("No session selected", severity="warning") return try: - # Use cached session HTML file directly - session_file = ( - self.project_path / f"session-{self.selected_session_id}.html" - ) + # Ensure HTML file exists and is up-to-date + session_file = self._ensure_session_file(self.selected_session_id, "html") + if session_file is None: + self.notify("Failed to generate HTML file", severity="error") + return webbrowser.open(f"file://{session_file}") - self.notify(f"Opened session HTML: {session_file}") + self.notify(f"Opened session HTML: {session_file.name}") except Exception as e: self.notify(f"Error opening session HTML: {e}", severity="error") def action_export_markdown(self) -> None: - """Export the selected session to Markdown.""" + """Export the selected session to Markdown and open in browser.""" if not self.selected_session_id: self.notify("No session selected", severity="warning") return try: - # Use cached session Markdown file directly - session_file = self.project_path / f"session-{self.selected_session_id}.md" + # Ensure Markdown file exists and is up-to-date + session_file = self._ensure_session_file(self.selected_session_id, "md") + if session_file is None: + self.notify("Failed to generate Markdown file", severity="error") + return webbrowser.open(f"file://{session_file}") - self.notify(f"Opened session Markdown: {session_file}") + self.notify(f"Opened session Markdown: {session_file.name}") except Exception as e: self.notify(f"Error opening session Markdown: {e}", severity="error") @@ -610,13 +615,10 @@ def action_view_markdown(self) -> None: return try: - session_file = self.project_path / f"session-{self.selected_session_id}.md" - - if not session_file.exists(): - self.notify( - f"Markdown file not found: {session_file.name}", - severity="warning", - ) + # Ensure Markdown file exists and is up-to-date + session_file = self._ensure_session_file(self.selected_session_id, "md") + if session_file is None: + self.notify("Failed to generate Markdown file", severity="error") return content = session_file.read_text(encoding="utf-8") @@ -718,6 +720,71 @@ def _update_expanded_content(self) -> None: expanded_content.update("\n".join(content_parts)) + def _ensure_session_file(self, session_id: str, format: str) -> Optional[Path]: + """Ensure the session file exists and is up-to-date. + + Regenerates the file if it doesn't exist or is outdated. + + Args: + session_id: The session ID to generate a file for. + format: Output format - "html" or "md". + + Returns: + Path to the file if successful, None if regeneration failed. + """ + ext = "html" if format == "html" else "md" + session_file = self.project_path / f"session-{session_id}.{ext}" + renderer = get_renderer(format) + + # Check if we need to regenerate + needs_regeneration = not session_file.exists() or renderer.is_outdated( + session_file + ) + + if not needs_regeneration: + return session_file + + # Load messages from JSONL files + try: + messages = load_directory_transcripts( + self.project_path, self.cache_manager, silent=True + ) + if not messages: + return None + + # Build session title + session_data = self.sessions.get(session_id) + project_cache = self.cache_manager.get_cached_project_data() + project_name = get_project_display_name( + self.project_path.name, + project_cache.working_directories if project_cache else None, + ) + if session_data and session_data.summary: + session_title = f"{project_name}: {session_data.summary}" + elif session_data and session_data.first_user_message: + preview = session_data.first_user_message + if len(preview) > 50: + preview = preview[:50] + "..." + session_title = f"{project_name}: {preview}" + else: + session_title = f"{project_name}: Session {session_id[:8]}" + + # Generate session content + session_content = renderer.generate_session( + messages, + session_id, + session_title, + self.cache_manager, + self.project_path, + ) + if session_content: + session_file.write_text(session_content, encoding="utf-8") + return session_file + except Exception: + return None + + return None + def action_toggle_expanded(self) -> None: """Toggle the expanded view for the selected session.""" if ( From 14da4989c596b10257ef4855d3252d03324a226a Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 10:23:44 +0100 Subject: [PATCH 10/22] Skip HTML generation in TUI mode (use on-demand via h/m/v) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace convert_jsonl_to_html() with ensure_fresh_cache() in TUI startup - TUI now only updates the cache, not output files - Users generate HTML/Markdown on-demand via h/m/v shortcuts - Faster TUI startup, especially for large projects ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/cli.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 461280e4..840adfdd 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -13,6 +13,7 @@ from .converter import ( convert_jsonl_to, convert_jsonl_to_html, + ensure_fresh_cache, process_projects_hierarchy, ) from .cache import CacheManager, get_library_version @@ -42,13 +43,13 @@ def _launch_tui_with_cache_check(project_path: Path) -> Optional[str]: else: click.echo("Building session cache...") - # Pre-build the cache before launching TUI + # Pre-build the cache before launching TUI (no HTML generation) try: - convert_jsonl_to_html(project_path, silent=True) + ensure_fresh_cache(project_path, cache_manager, silent=True) click.echo("Cache ready! Launching TUI...") except Exception as e: click.echo(f"Error building cache: {e}", err=True) - return + return None else: click.echo( f"Cache up to date. Found {len(project_cache.sessions)} sessions. Launching TUI..." From bffd4fc238dce5161361c282a9f5a0f82a5e3de7 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 12:25:12 +0100 Subject: [PATCH 11/22] Use MarkdownViewer with ToC in embedded viewer (v shortcut) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Markdown widget with MarkdownViewer for built-in navigation - Enable show_table_of_contents=True for outline navigation - Update footer hint to mention 't' for ToC toggle - Increase viewer size to 95% for better readability - Remove unused VerticalScroll import ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index f743ab63..7c978e52 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -9,14 +9,14 @@ from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import Container, Vertical, VerticalScroll +from textual.containers import Container, Vertical from textual.screen import ModalScreen from textual.widgets import ( DataTable, Footer, Header, Label, - Markdown, + MarkdownViewer, Static, ) from textual.reactive import reactive @@ -183,7 +183,7 @@ async def action_quit(self) -> None: class MarkdownViewerScreen(ModalScreen[None]): - """Modal screen for viewing Markdown content.""" + """Modal screen for viewing Markdown content with table of contents.""" CSS = """ MarkdownViewerScreen { @@ -191,8 +191,8 @@ class MarkdownViewerScreen(ModalScreen[None]): } #md-container { - width: 90%; - height: 90%; + width: 95%; + height: 95%; border: solid $primary; background: $surface; } @@ -206,9 +206,8 @@ class MarkdownViewerScreen(ModalScreen[None]): padding: 1; } - #md-content { + #md-viewer { height: 1fr; - padding: 1 2; } #md-footer { @@ -233,9 +232,10 @@ def __init__(self, content: str, title: str = "Markdown Viewer") -> None: def compose(self) -> ComposeResult: with Container(id="md-container"): yield Static(self.md_title, id="md-header") - with VerticalScroll(id="md-content"): - yield Markdown(self.md_content) - yield Static("Press ESC or q to close", id="md-footer") + yield MarkdownViewer( + self.md_content, id="md-viewer", show_table_of_contents=True + ) + yield Static("Press ESC or q to close | t: toggle ToC", id="md-footer") def action_dismiss(self) -> None: self.dismiss(None) From 3b94efbd0d1df213ece67c6d86046c8d82260882 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 12:45:56 +0100 Subject: [PATCH 12/22] Customize MarkdownViewer ToC: 1/3 width, expand 3 levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CSS to limit ToC width to max 60 columns (~1/3 of viewer) - Add on_mount hook to collapse tree beyond depth 3 after loading (root + children + grandchildren visible, matching HTML fold state) - Import Tree widget for proper type resolution ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index 7c978e52..67655a23 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -18,6 +18,7 @@ Label, MarkdownViewer, Static, + Tree, ) from textual.reactive import reactive @@ -210,6 +211,11 @@ class MarkdownViewerScreen(ModalScreen[None]): height: 1fr; } + /* Limit ToC width to ~1/3 of the viewer */ + #md-viewer MarkdownTableOfContents { + max-width: 60; + } + #md-footer { dock: bottom; height: 1; @@ -237,6 +243,27 @@ def compose(self) -> ComposeResult: ) yield Static("Press ESC or q to close | t: toggle ToC", id="md-footer") + def on_mount(self) -> None: + """Collapse ToC tree to show only first two levels after mount.""" + self.call_later(self._collapse_toc_tree) + + def _collapse_toc_tree(self) -> None: + """Collapse all tree nodes beyond depth 3 (root + 2 levels).""" + try: + viewer = self.query_one("#md-viewer", MarkdownViewer) + # Access the tree inside the table of contents + toc = viewer.query_one("MarkdownTableOfContents") + tree = toc.query_one(Tree) + # Collapse all, then expand root, children, and grandchildren + tree.root.collapse_all() + tree.root.expand() + for child in tree.root.children: + child.expand() + for grandchild in child.children: + grandchild.expand() + except Exception: + pass # ToC might not be ready yet, or tree structure differs + def action_dismiss(self) -> None: self.dismiss(None) From 44a13708d511a80ff7cd1f642884f084874e6566 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 12:55:55 +0100 Subject: [PATCH 13/22] Extract shared regex patterns in snapshot serializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor out version comment and tmp path patterns into module-level constants (_VERSION_PATTERN, _TMP_PATH_PATTERN) to prevent drift between HTML and Markdown serializers. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/snapshot_serializers.py | 47 ++++++++++++------------------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/test/snapshot_serializers.py b/test/snapshot_serializers.py index 7a29d1d8..a61a1022 100644 --- a/test/snapshot_serializers.py +++ b/test/snapshot_serializers.py @@ -6,6 +6,18 @@ from syrupy.extensions.amber import AmberSnapshotExtension from syrupy.types import SerializableData +# Shared normalisation patterns (used by both HTML and Markdown serializers) +# Library version comment - changes between releases +_VERSION_PATTERN = r"" +_VERSION_REPLACEMENT = "" + +# Pytest tmp paths - varies per test run +# Handles: /tmp/pytest-123/..., /private/var/folders/.../tmp/pytest-123/... +_TMP_PATH_PATTERN = ( + r"(/private)?(/var/folders/[^/]+/[^/]+/[^/]+/)?/tmp/pytest-[^\s\"'<>]+" +) +_TMP_PATH_REPLACEMENT = "/[TMP_PATH]" + class NormalisedMarkdownSerializer(AmberSnapshotExtension): """ @@ -26,21 +38,8 @@ def serialize(cls, data: SerializableData, **kwargs: Any) -> str: @classmethod def _normalise_markdown(cls, md: str) -> str: """Apply normalisation rules to Markdown content.""" - # Library version (changes between releases) - # -> - md = re.sub( - r"", - "", - md, - ) - - # Pytest tmp paths (from tmp_path fixture) - md = re.sub( - r"(/private)?(/var/folders/[^/]+/[^/]+/[^/]+/)?/tmp/pytest-[^\s\"'<>]+", - "/[TMP_PATH]", - md, - ) - + md = re.sub(_VERSION_PATTERN, _VERSION_REPLACEMENT, md) + md = re.sub(_TMP_PATH_PATTERN, _TMP_PATH_REPLACEMENT, md) return md @@ -66,22 +65,8 @@ def serialize(cls, data: SerializableData, **kwargs: Any) -> str: @classmethod def _normalise_html(cls, html: str) -> str: """Apply normalisation rules to HTML content.""" - # Library version (changes between releases) - # -> - html = re.sub( - r"", - "", - html, - ) - - # Pytest tmp paths (from tmp_path fixture) - # /tmp/pytest-123/test_foo0/ -> /[TMP_PATH]/ - # /private/var/folders/.../pytest-123/... -> /[TMP_PATH]/ - html = re.sub( - r"(/private)?(/var/folders/[^/]+/[^/]+/[^/]+/)?/tmp/pytest-[^\s\"'<>]+", - "/[TMP_PATH]", - html, - ) + html = re.sub(_VERSION_PATTERN, _VERSION_REPLACEMENT, html) + html = re.sub(_TMP_PATH_PATTERN, _TMP_PATH_REPLACEMENT, html) # Last modified timestamps in project index (timezone-dependent) # ๐Ÿ•’ 2023-11-14 23:15:00 -> ๐Ÿ•’ [LAST_MODIFIED] From d88287612dd8e79c2f6b8c81d55a16a8b30129ef Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 13:10:53 +0100 Subject: [PATCH 14/22] Remove roman numeral prefixes from MarkdownViewer ToC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip Textual's default level indicators (โ… , โ…ก, etc.) from ToC tree labels for a cleaner outline view. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index 67655a23..d3dd3e8f 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -5,7 +5,7 @@ import webbrowser from datetime import datetime from pathlib import Path -from typing import ClassVar, Optional, cast +from typing import Any, ClassVar, Optional, cast from textual.app import App, ComposeResult from textual.binding import Binding, BindingType @@ -244,16 +244,19 @@ def compose(self) -> ComposeResult: yield Static("Press ESC or q to close | t: toggle ToC", id="md-footer") def on_mount(self) -> None: - """Collapse ToC tree to show only first two levels after mount.""" - self.call_later(self._collapse_toc_tree) + """Customize ToC tree after mount.""" + self.call_later(self._customize_toc_tree) - def _collapse_toc_tree(self) -> None: - """Collapse all tree nodes beyond depth 3 (root + 2 levels).""" + def _customize_toc_tree(self) -> None: + """Customize ToC: collapse to 3 levels and remove roman numeral prefixes.""" try: viewer = self.query_one("#md-viewer", MarkdownViewer) - # Access the tree inside the table of contents toc = viewer.query_one("MarkdownTableOfContents") tree = toc.query_one(Tree) + + # Remove roman numeral prefixes from all labels + self._strip_roman_numerals(tree.root) + # Collapse all, then expand root, children, and grandchildren tree.root.collapse_all() tree.root.expand() @@ -264,6 +267,17 @@ def _collapse_toc_tree(self) -> None: except Exception: pass # ToC might not be ready yet, or tree structure differs + def _strip_roman_numerals(self, node: Any) -> None: + """Recursively strip roman numeral prefixes from tree node labels.""" + # Unicode roman numerals used by Textual's MarkdownTableOfContents + roman_numerals = "โ… โ…กโ…ขโ…ฃโ…คโ…ฅ" + label = str(node.label) + # Strip leading roman numeral and space (e.g., "โ…ก Heading" -> "Heading") + if label and label[0] in roman_numerals: + node.set_label(label[2:] if len(label) > 1 else label) + for child in node.children: + self._strip_roman_numerals(child) + def action_dismiss(self) -> None: self.dismiss(None) From e7455d4640a14abc9719c7bffe8b4f7cdfb198e3 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 16:45:21 +0100 Subject: [PATCH 15/22] Fix ToC label cleaning to handle emoji prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use str.replace() instead of startswith() so message type prefixes ("User: ", "Assistant: ", "Thinking: ") are stripped even when they appear after the emoji icon in ToC labels. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/markdown/renderer.py | 12 ++++----- claude_code_log/tui.py | 25 ++++++++++++++----- .../__snapshots__/test_snapshot_markdown.ambr | 8 +++--- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index ebf14402..eaac4925 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -640,16 +640,16 @@ def title_ReadInput(self, input: ReadInput, _: TemplateMessage) -> str: return f"๐Ÿ‘€ Read `{Path(input.file_path).name}`" def title_WriteInput(self, input: WriteInput, _: TemplateMessage) -> str: - """Title โ†’ 'โœ๏ธ Write `filename`'.""" - return f"โœ๏ธ Write `{Path(input.file_path).name}`" + """Title โ†’ 'โœ๏ธ Write `filename`'.""" + return f"โœ๏ธ Write `{Path(input.file_path).name}`" def title_EditInput(self, input: EditInput, _: TemplateMessage) -> str: - """Title โ†’ 'โœ๏ธ Edit `filename`'.""" - return f"โœ๏ธ Edit `{Path(input.file_path).name}`" + """Title โ†’ 'โœ๏ธ Edit `filename`'.""" + return f"โœ๏ธ Edit `{Path(input.file_path).name}`" def title_MultiEditInput(self, input: MultiEditInput, _: TemplateMessage) -> str: - """Title โ†’ 'โœ๏ธ MultiEdit `filename`'.""" - return f"โœ๏ธ MultiEdit `{Path(input.file_path).name}`" + """Title โ†’ 'โœ๏ธ MultiEdit `filename`'.""" + return f"โœ๏ธ MultiEdit `{Path(input.file_path).name}`" def title_GlobInput(self, input: GlobInput, _: TemplateMessage) -> str: """Title โ†’ '๐Ÿ“‚ Glob `pattern`[ in `path`]'.""" diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index d3dd3e8f..8ca91275 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -254,8 +254,8 @@ def _customize_toc_tree(self) -> None: toc = viewer.query_one("MarkdownTableOfContents") tree = toc.query_one(Tree) - # Remove roman numeral prefixes from all labels - self._strip_roman_numerals(tree.root) + # Clean up labels (remove roman numerals and message type prefixes) + self._clean_toc_labels(tree.root) # Collapse all, then expand root, children, and grandchildren tree.root.collapse_all() @@ -267,16 +267,29 @@ def _customize_toc_tree(self) -> None: except Exception: pass # ToC might not be ready yet, or tree structure differs - def _strip_roman_numerals(self, node: Any) -> None: - """Recursively strip roman numeral prefixes from tree node labels.""" + def _clean_toc_labels(self, node: Any) -> None: + """Recursively clean tree node labels for a cleaner ToC.""" # Unicode roman numerals used by Textual's MarkdownTableOfContents roman_numerals = "โ… โ…กโ…ขโ…ฃโ…คโ…ฅ" + # Message type prefixes that add clutter in ToC context + clutter_prefixes = ("User: ", "Assistant: ", "Thinking: ") + label = str(node.label) + # Strip leading roman numeral and space (e.g., "โ…ก Heading" -> "Heading") if label and label[0] in roman_numerals: - node.set_label(label[2:] if len(label) > 1 else label) + label = label[2:] if len(label) > 1 else label + + # Strip message type prefixes wherever they appear + # (they come after the emoji, e.g., "๐Ÿคท User: *text*" -> "๐Ÿคท *text*") + for prefix in clutter_prefixes: + if prefix in label: + label = label.replace(prefix, "", 1) + break + + node.set_label(label) for child in node.children: - self._strip_roman_numerals(child) + self._clean_toc_labels(child) def action_dismiss(self) -> None: self.dismiss(None) diff --git a/test/__snapshots__/test_snapshot_markdown.ambr b/test/__snapshots__/test_snapshot_markdown.ambr index 0c45c779..fafc9b58 100644 --- a/test/__snapshots__/test_snapshot_markdown.ambr +++ b/test/__snapshots__/test_snapshot_markdown.ambr @@ -71,7 +71,7 @@ Great! Can you also show me how to create a decorator that takes parameters? ``` - ### โœ๏ธ Edit `decorator_example.py` + ### โœ๏ธ Edit `decorator_example.py` ```diff @@ -0,0 +1,14 @@+def repeat(times): @@ -234,7 +234,7 @@ > I see the long Lorem ipsum text wraps nicely! Long text handling is important for readability. The CSS should handle word wrapping automatically. - #### โœ๏ธ MultiEdit `complex_example.py` + #### โœ๏ธ MultiEdit `complex_example.py` **Edit 1:** @@ -395,7 +395,7 @@ Great! Can you also show me how to create a decorator that takes parameters? ``` - ### โœ๏ธ Edit `decorator_example.py` + ### โœ๏ธ Edit `decorator_example.py` ```diff @@ -0,0 +1,14 @@+def repeat(times): @@ -526,7 +526,7 @@ Great! Can you also show me how to create a decorator that takes parameters? ``` - ### โœ๏ธ Edit `decorator_example.py` + ### โœ๏ธ Edit `decorator_example.py` ```diff @@ -0,0 +1,14 @@+def repeat(times): From 5b224a1310164dedca4d4eabb4ea7f2a9493f170 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 16:51:57 +0100 Subject: [PATCH 16/22] Add excerpts to sidechain titles and simplify ToC labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown titles: - Add excerpts to Sub-assistant titles (was just "Sub-assistant") - Handle sidechain thinking messages correctly ToC label cleanup: - Strip "Sub-assistant: " prefix (like User/Assistant/Thinking) - Simplify "Task (details): " to "Task: " (details are redundant) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/markdown/renderer.py | 12 ++++++++++-- claude_code_log/tui.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index eaac4925..94a07cff 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -688,16 +688,22 @@ def title_ThinkingMessage( self, _content: ThinkingMessage, _message: TemplateMessage ) -> str: """Title โ†’ '๐Ÿค– Assistant: *excerpt*' (paired) or '๐Ÿ’ญ Thinking: *excerpt*'.""" + is_sidechain = _message.meta.is_sidechain + # When paired with Assistant, use Assistant title with assistant excerpt if _message.is_first_in_pair and _message.pair_last is not None: if ( pair_msg := self._ctx.get(_message.pair_last) if self._ctx else None ) and isinstance(pair_msg.content, AssistantTextMessage): + if is_sidechain: + if excerpt := self._excerpt(self._get_message_text(pair_msg)): + return f"๐Ÿ”— Sub-assistant: *{self._escape_stars(excerpt)}*" + return "๐Ÿ”— Sub-assistant" if excerpt := self._excerpt(self._get_message_text(pair_msg)): return f"๐Ÿค– Assistant: *{self._escape_stars(excerpt)}*" return "๐Ÿค– Assistant" - # Standalone thinking + # Standalone thinking (use "Thinking" for both main and sidechain) if excerpt := self._excerpt(self._get_message_text(_message)): return f"๐Ÿ’ญ Thinking: *{self._escape_stars(excerpt)}*" return "๐Ÿ’ญ Thinking" @@ -709,8 +715,10 @@ def title_AssistantTextMessage( # When paired (after Thinking), skip title (already rendered with Thinking) if message.is_last_in_pair: return "" - # Sidechain assistant messages get special title + # Sidechain assistant messages get excerpt too if message.meta.is_sidechain: + if excerpt := self._excerpt(self._get_message_text(message)): + return f"๐Ÿ”— Sub-assistant: *{self._escape_stars(excerpt)}*" return "๐Ÿ”— Sub-assistant" if excerpt := self._excerpt(self._get_message_text(message)): return f"๐Ÿค– Assistant: *{self._escape_stars(excerpt)}*" diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index 8ca91275..75851559 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -269,10 +269,17 @@ def _customize_toc_tree(self) -> None: def _clean_toc_labels(self, node: Any) -> None: """Recursively clean tree node labels for a cleaner ToC.""" + import re + # Unicode roman numerals used by Textual's MarkdownTableOfContents roman_numerals = "โ… โ…กโ…ขโ…ฃโ…คโ…ฅ" # Message type prefixes that add clutter in ToC context - clutter_prefixes = ("User: ", "Assistant: ", "Thinking: ") + clutter_prefixes = ( + "User: ", + "Assistant: ", + "Thinking: ", + "Sub-assistant: ", + ) label = str(node.label) @@ -287,6 +294,9 @@ def _clean_toc_labels(self, node: Any) -> None: label = label.replace(prefix, "", 1) break + # Simplify "Task (details): " to "Task: " (details are redundant) + label = re.sub(r"Task \([^)]+\): ", "Task: ", label) + node.set_label(label) for child in node.children: self._clean_toc_labels(child) From b2656887ab38820e26b347c31020410ff3f013db Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 16:59:20 +0100 Subject: [PATCH 17/22] Add hidden H/M/V shortcuts for force-regenerate in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hidden bindings (show=False) for uppercase H, M, V shortcuts - H: Force regenerate HTML and open in browser - M: Force regenerate Markdown and open in browser - V: Force regenerate Markdown and view in embedded viewer - Refactor export actions into shared helpers (_export_to_browser, _view_markdown_embedded) with force parameter - Add force parameter to _ensure_session_file ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 93 ++++++++++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index 75851559..89dd82c5 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -348,6 +348,10 @@ class SessionBrowser(App[Optional[str]]): Binding("h", "export_selected", "Open HTML page"), Binding("m", "export_markdown", "Open Markdown"), Binding("v", "view_markdown", "View Markdown"), + # Hidden "force regenerate" variants (uppercase) + Binding("H", "force_export_html", "Force HTML", show=False), + Binding("M", "force_export_markdown", "Force Markdown", show=False), + Binding("V", "force_view_markdown", "Force View", show=False), Binding("c", "resume_selected", "Resume in Claude Code"), Binding("e", "toggle_expanded", "Toggle Expanded View"), Binding("p", "back_to_projects", "Open Project Selector"), @@ -634,63 +638,87 @@ def _update_selected_session_from_cursor(self) -> None: # If widget not mounted yet or we can't get the row data, don't update selection pass - def action_export_selected(self) -> None: - """Export the selected session to HTML and open in browser.""" + def _export_to_browser(self, format: str, *, force: bool = False) -> None: + """Export session to file and open in browser. + + Args: + format: Output format - "html" or "md". + force: If True, always regenerate even if file is up-to-date. + """ if not self.selected_session_id: self.notify("No session selected", severity="warning") return + format_name = "HTML" if format == "html" else "Markdown" try: - # Ensure HTML file exists and is up-to-date - session_file = self._ensure_session_file(self.selected_session_id, "html") + session_file = self._ensure_session_file( + self.selected_session_id, format, force=force + ) if session_file is None: - self.notify("Failed to generate HTML file", severity="error") + self.notify(f"Failed to generate {format_name} file", severity="error") return webbrowser.open(f"file://{session_file}") - self.notify(f"Opened session HTML: {session_file.name}") + msg = ( + f"Regenerated: {session_file.name}" + if force + else f"Opened: {session_file.name}" + ) + self.notify(msg) except Exception as e: - self.notify(f"Error opening session HTML: {e}", severity="error") + self.notify(f"Error with {format_name}: {e}", severity="error") - def action_export_markdown(self) -> None: - """Export the selected session to Markdown and open in browser.""" + def _view_markdown_embedded(self, *, force: bool = False) -> None: + """View session Markdown in embedded viewer. + + Args: + force: If True, always regenerate even if file is up-to-date. + """ if not self.selected_session_id: self.notify("No session selected", severity="warning") return try: - # Ensure Markdown file exists and is up-to-date - session_file = self._ensure_session_file(self.selected_session_id, "md") + session_file = self._ensure_session_file( + self.selected_session_id, "md", force=force + ) if session_file is None: self.notify("Failed to generate Markdown file", severity="error") return - webbrowser.open(f"file://{session_file}") - self.notify(f"Opened session Markdown: {session_file.name}") + content = session_file.read_text(encoding="utf-8") + title = f"Session: {self.selected_session_id[:8]}..." + self.push_screen(MarkdownViewerScreen(content, title)) + if force: + self.notify(f"Regenerated: {session_file.name}") except Exception as e: - self.notify(f"Error opening session Markdown: {e}", severity="error") + self.notify(f"Error viewing Markdown: {e}", severity="error") + + def action_export_selected(self) -> None: + """Export the selected session to HTML and open in browser.""" + self._export_to_browser("html") + + def action_export_markdown(self) -> None: + """Export the selected session to Markdown and open in browser.""" + self._export_to_browser("md") def action_view_markdown(self) -> None: """View the selected session's Markdown in an embedded viewer.""" - if not self.selected_session_id: - self.notify("No session selected", severity="warning") - return + self._view_markdown_embedded() - try: - # Ensure Markdown file exists and is up-to-date - session_file = self._ensure_session_file(self.selected_session_id, "md") - if session_file is None: - self.notify("Failed to generate Markdown file", severity="error") - return + def action_force_export_html(self) -> None: + """Force regenerate HTML and open in browser (hidden shortcut: H).""" + self._export_to_browser("html", force=True) - content = session_file.read_text(encoding="utf-8") - title = f"Session: {self.selected_session_id[:8]}..." - self.push_screen(MarkdownViewerScreen(content, title)) + def action_force_export_markdown(self) -> None: + """Force regenerate Markdown and open in browser (hidden shortcut: M).""" + self._export_to_browser("md", force=True) - except Exception as e: - self.notify(f"Error viewing Markdown: {e}", severity="error") + def action_force_view_markdown(self) -> None: + """Force regenerate and view Markdown in embedded viewer (hidden shortcut: V).""" + self._view_markdown_embedded(force=True) def action_resume_selected(self) -> None: """Resume the selected session in Claude Code.""" @@ -784,7 +812,9 @@ def _update_expanded_content(self) -> None: expanded_content.update("\n".join(content_parts)) - def _ensure_session_file(self, session_id: str, format: str) -> Optional[Path]: + def _ensure_session_file( + self, session_id: str, format: str, *, force: bool = False + ) -> Optional[Path]: """Ensure the session file exists and is up-to-date. Regenerates the file if it doesn't exist or is outdated. @@ -792,6 +822,7 @@ def _ensure_session_file(self, session_id: str, format: str) -> Optional[Path]: Args: session_id: The session ID to generate a file for. format: Output format - "html" or "md". + force: If True, always regenerate even if file is up-to-date. Returns: Path to the file if successful, None if regeneration failed. @@ -801,8 +832,8 @@ def _ensure_session_file(self, session_id: str, format: str) -> Optional[Path]: renderer = get_renderer(format) # Check if we need to regenerate - needs_regeneration = not session_file.exists() or renderer.is_outdated( - session_file + needs_regeneration = ( + force or not session_file.exists() or renderer.is_outdated(session_file) ) if not needs_regeneration: From 80a69cd1200922160a8d2f8769aeac2770fdb03a Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 17:02:47 +0100 Subject: [PATCH 18/22] Fix type checker errors in tui.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cast Tree query result to Tree[Any] to satisfy pyright - Make action_dismiss async with proper signature to match Screen base class ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/tui.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index 89dd82c5..0b1a99b5 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -252,7 +252,7 @@ def _customize_toc_tree(self) -> None: try: viewer = self.query_one("#md-viewer", MarkdownViewer) toc = viewer.query_one("MarkdownTableOfContents") - tree = toc.query_one(Tree) + tree = cast(Tree[Any], toc.query_one(Tree)) # Clean up labels (remove roman numerals and message type prefixes) self._clean_toc_labels(tree.root) @@ -301,8 +301,8 @@ def _clean_toc_labels(self, node: Any) -> None: for child in node.children: self._clean_toc_labels(child) - def action_dismiss(self) -> None: - self.dismiss(None) + async def action_dismiss(self, result: None = None) -> None: + self.dismiss(result) class SessionBrowser(App[Optional[str]]): From 6e1e4e1eaf032975bd66b48e16e4218971dea444 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 17:09:41 +0100 Subject: [PATCH 19/22] Document Markdown export and TUI shortcuts in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update description to mention both HTML and Markdown formats - Add TUI shortcuts: m (Markdown), v (embedded viewer), H/M/V (force regenerate) - Add .md files to output file listings - Add new "Markdown Output Features" section ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e863ea55..3cabb9b8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Code Log -A Python CLI tool that converts Claude Code transcript JSONL files into readable HTML format. +A Python CLI tool that converts Claude Code transcript JSONL files into readable HTML and Markdown formats. Browser log demo: @@ -81,10 +81,13 @@ claude-code-log my-project --tui # Automatically converts to ~/.claude/projects - **Smart Summaries**: Prioritizes Claude-generated summaries over first user messages for better session identification - **Working Directory Matching**: Automatically finds and opens projects matching your current working directory - **Quick Actions**: - - `h` or "Export to HTML" button: Generate and open session HTML in browser - - `c` or "Resume in Claude Code" button: Continue session with `claude -r ` - - `r` or "Refresh" button: Reload session data from files - - `p` or "Projects View" button: Switch to project selector view + - `h`: Generate and open session HTML in browser + - `m`: Generate and open session Markdown in browser + - `v`: View session Markdown in embedded viewer (with table of contents) + - `c`: Resume session in Claude Code with `claude -r ` + - `r`: Reload session data from files + - `p`: Switch to project selector view + - `H`/`M`/`V`: Force regenerate HTML/Markdown (hidden shortcuts for development) - **Project Statistics**: Real-time display of total sessions, messages, tokens, and date range - **Cache Integration**: Leverages existing cache system for fast loading with automatic cache validation - **Keyboard Navigation**: Arrow keys to navigate, Enter to expand row details, `q` to quit @@ -115,6 +118,7 @@ This creates: - `~/.claude/projects/index.html` - Top level index with project cards and statistics - `~/.claude/projects/project-name/combined_transcripts.html` - Individual project pages (these can be several megabytes) - `~/.claude/projects/project-name/session-{session-id}.html` - Individual session pages +- `~/.claude/projects/project-name/session-{session-id}.md` - Markdown versions (generated on-demand via TUI) ### Single File or Directory Processing @@ -146,6 +150,7 @@ When processing all projects, the tool generates: โ”œโ”€โ”€ project1/ โ”‚ โ”œโ”€โ”€ combined_transcripts.html # Combined project page โ”‚ โ”œโ”€โ”€ session-{session-id}.html # Individual session pages +โ”‚ โ”œโ”€โ”€ session-{session-id}.md # Markdown version (on-demand via TUI) โ”‚ โ””โ”€โ”€ session-{session-id2}.html # More session pages... โ”œโ”€โ”€ project2/ โ”‚ โ”œโ”€โ”€ combined_transcripts.html @@ -189,6 +194,17 @@ When processing all projects, the tool generates: - **Floating Controls**: Always-available filter button, details toggle, and back-to-top navigation - **Cross-Session Features**: Summaries properly matched across async sessions +## Markdown Output Features + +Markdown export provides a lightweight, portable alternative to HTML: + +- **GitHub-Flavored Markdown**: Compatible with GitHub, GitLab, and other Markdown renderers +- **Hierarchical Structure**: Sessions organized with headers and collapsible details +- **Message Excerpts**: Section titles include message previews for quick navigation +- **Code Preservation**: Syntax highlighting hints via fenced code blocks +- **Embedded Viewer**: TUI includes built-in Markdown viewer with table of contents +- **Image Support**: Configurable image handling (placeholder, embedded base64, or referenced files) + ## Installation Install using pip: From 75ac3f6c46040d2f9ffdcd3c7e93101449c881ce Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 17:12:03 +0100 Subject: [PATCH 20/22] Hoist strip_error_tags import to module scope in test file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces repetition by moving the import from inside each test method to the module level. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/test_markdown_helpers.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/test/test_markdown_helpers.py b/test/test_markdown_helpers.py index 5d680811..0fe668c1 100644 --- a/test/test_markdown_helpers.py +++ b/test/test_markdown_helpers.py @@ -6,6 +6,7 @@ import pytest from claude_code_log.markdown.renderer import MarkdownRenderer +from claude_code_log.utils import strip_error_tags @pytest.fixture @@ -212,16 +213,12 @@ class TestStripErrorTags: def test_simple_error(self): """Single error tag is stripped, content preserved.""" - from claude_code_log.utils import strip_error_tags - text = "File not found" result = strip_error_tags(text) assert result == "File not found" def test_multiline_error(self): """Multiline error content is preserved.""" - from claude_code_log.utils import strip_error_tags - text = ( "String to replace not found.\nString: foo" ) @@ -230,24 +227,18 @@ def test_multiline_error(self): def test_no_error_tag(self): """Text without error tags is unchanged.""" - from claude_code_log.utils import strip_error_tags - text = "Normal tool output" result = strip_error_tags(text) assert result == "Normal tool output" def test_nested_content(self): """Error with complex content is handled.""" - from claude_code_log.utils import strip_error_tags - text = "Error: Code has brackets" result = strip_error_tags(text) assert result == "Error: Code has brackets" def test_empty_error(self): """Empty error tag produces empty string.""" - from claude_code_log.utils import strip_error_tags - text = "" result = strip_error_tags(text) assert result == "" From 4849e2d15d9742de9ea0a8c5a2b23dfbdca56d7d Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 17:26:27 +0100 Subject: [PATCH 21/22] Unify file extension computation using get_file_extension() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace inline "md" if ... else "html" patterns with get_file_extension() - Update cli.py (2 places), converter.py (1 place), and tui.py (1 place) - Add get_file_extension import to cli.py and tui.py ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/cli.py | 5 +++-- claude_code_log/converter.py | 2 +- claude_code_log/tui.py | 8 ++++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index 840adfdd..cef23725 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -14,6 +14,7 @@ convert_jsonl_to, convert_jsonl_to_html, ensure_fresh_cache, + get_file_extension, process_projects_hierarchy, ) from .cache import CacheManager, get_library_version @@ -573,7 +574,7 @@ def main( # Handle output files clearing if clear_output: - file_ext = "md" if output_format in ("md", "markdown") else "html" + file_ext = get_file_extension(output_format) _clear_output_files(input_path, all_projects, file_ext) if clear_output and not (from_date or to_date or input_path.is_file()): # If only clearing output files, exit after clearing @@ -651,7 +652,7 @@ def main( else: jsonl_count = len(list(input_path.glob("*.jsonl"))) if not no_individual_sessions: - ext = "md" if output_format in ("md", "markdown") else "html" + ext = get_file_extension(output_format) session_files = list(input_path.glob(f"session-*.{ext}")) click.echo( f"Successfully combined {jsonl_count} transcript files from {input_path} to {output_path} and generated {len(session_files)} individual session files" diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 4bb7eec6..46f2e699 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -1167,7 +1167,7 @@ def process_projects_hierarchy( continue # Generate index (always regenerate if outdated) - ext = "md" if output_format in ("md", "markdown") else "html" + ext = get_file_extension(output_format) index_path = projects_path / f"index.{ext}" renderer = get_renderer(output_format, image_export_mode) if renderer.is_outdated(index_path) or from_date or to_date or any_cache_updated: diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index 0b1a99b5..760dd3d3 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -23,7 +23,11 @@ from textual.reactive import reactive from .cache import CacheManager, SessionCacheData, get_library_version -from .converter import ensure_fresh_cache, load_directory_transcripts +from .converter import ( + ensure_fresh_cache, + get_file_extension, + load_directory_transcripts, +) from .renderer import get_renderer from .utils import get_project_display_name @@ -827,7 +831,7 @@ def _ensure_session_file( Returns: Path to the file if successful, None if regeneration failed. """ - ext = "html" if format == "html" else "md" + ext = get_file_extension(format) session_file = self.project_path / f"session-{session_id}.{ext}" renderer = get_renderer(format) From c8f7e879f1bfea1e3b23caaeb144caac1808ebe0 Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Wed, 31 Dec 2025 17:30:09 +0100 Subject: [PATCH 22/22] Simplify output_dir assignment after assert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant conditional that can never take the else path since output_path is guaranteed non-None after the assert. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- claude_code_log/converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 46f2e699..b6175992 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -525,7 +525,7 @@ def convert_jsonl_to( if should_regenerate: # For referenced images, pass the output directory - output_dir = output_path.parent if output_path else input_path + output_dir = output_path.parent content = renderer.generate(messages, title, output_dir=output_dir) assert content is not None output_path.write_text(content, encoding="utf-8")