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: diff --git a/claude_code_log/cli.py b/claude_code_log/cli.py index c325efcf..cef23725 100644 --- a/claude_code_log/cli.py +++ b/claude_code_log/cli.py @@ -13,6 +13,8 @@ from .converter import ( convert_jsonl_to, convert_jsonl_to_html, + ensure_fresh_cache, + get_file_extension, process_projects_hierarchy, ) from .cache import CacheManager, get_library_version @@ -42,13 +44,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..." @@ -290,12 +292,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 +308,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 +365,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 +403,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 +450,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 +572,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 = 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 + click.echo(f"{file_ext.upper()} files cleared successfully.") return # Handle --all-projects flag or default behavior @@ -646,7 +652,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 = 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..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") @@ -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/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 diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index c1589903..94a07cff 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,62 +630,80 @@ 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: - 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: - 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: - 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`]'.""" 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*'.""" + 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" @@ -658,11 +711,14 @@ 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 "" - # 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/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 ): diff --git a/claude_code_log/tui.py b/claude_code_log/tui.py index f3a69b5b..760dd3d3 100644 --- a/claude_code_log/tui.py +++ b/claude_code_log/tui.py @@ -5,22 +5,30 @@ 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 from textual.containers import Container, Vertical +from textual.screen import ModalScreen from textual.widgets import ( DataTable, Footer, Header, Label, + MarkdownViewer, Static, + Tree, ) 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, + get_file_extension, + load_directory_transcripts, +) +from .renderer import get_renderer from .utils import get_project_display_name @@ -179,6 +187,128 @@ async def action_quit(self) -> None: self.exit(None) +class MarkdownViewerScreen(ModalScreen[None]): + """Modal screen for viewing Markdown content with table of contents.""" + + CSS = """ + MarkdownViewerScreen { + align: center middle; + } + + #md-container { + width: 95%; + height: 95%; + border: solid $primary; + background: $surface; + } + + #md-header { + dock: top; + height: 3; + background: $primary; + color: $text; + text-align: center; + padding: 1; + } + + #md-viewer { + height: 1fr; + } + + /* Limit ToC width to ~1/3 of the viewer */ + #md-viewer MarkdownTableOfContents { + max-width: 60; + } + + #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") + 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 on_mount(self) -> None: + """Customize ToC tree after mount.""" + self.call_later(self._customize_toc_tree) + + 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) + toc = viewer.query_one("MarkdownTableOfContents") + tree = cast(Tree[Any], toc.query_one(Tree)) + + # 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() + 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 _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: ", + "Sub-assistant: ", + ) + + label = str(node.label) + + # Strip leading roman numeral and space (e.g., "Ⅱ Heading" -> "Heading") + if label and label[0] in roman_numerals: + 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 + + # 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) + + async def action_dismiss(self, result: None = None) -> None: + self.dismiss(result) + + class SessionBrowser(App[Optional[str]]): """Interactive TUI for browsing and managing Claude Code Log sessions.""" @@ -220,6 +350,12 @@ 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("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"), @@ -506,23 +642,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.""" + 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: - # Use cached session HTML file directly - session_file = ( - self.project_path / f"session-{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(f"Failed to generate {format_name} file", severity="error") + return webbrowser.open(f"file://{session_file}") - self.notify(f"Opened session HTML: {session_file}") + 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 with {format_name}: {e}", severity="error") + + 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: + 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 + + 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 HTML: {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.""" + self._view_markdown_embedded() + + def action_force_export_html(self) -> None: + """Force regenerate HTML and open in browser (hidden shortcut: H).""" + self._export_to_browser("html", force=True) + + def action_force_export_markdown(self) -> None: + """Force regenerate Markdown and open in browser (hidden shortcut: M).""" + self._export_to_browser("md", force=True) + + 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.""" @@ -616,6 +816,74 @@ def _update_expanded_content(self) -> None: expanded_content.update("\n".join(content_parts)) + 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. + + 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. + """ + ext = get_file_extension(format) + session_file = self.project_path / f"session-{session_id}.{ext}" + renderer = get_renderer(format) + + # Check if we need to regenerate + needs_regeneration = ( + force or 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 ( @@ -646,7 +914,9 @@ 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 (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" 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 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): 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] 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 == ""