Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3973f14
Add minimal docstrings to MarkdownRenderer methods
cboos Dec 29, 2025
9159e3b
Fix session file count for Markdown output format
cboos Dec 31, 2025
a89bfe6
Fix timing lambdas to avoid UnboundLocalError on exception
cboos Dec 31, 2025
31d5ab3
Add error handling for image export file operations
cboos Dec 31, 2025
cf9db4d
Update style guide script docstring to mention Markdown output
cboos Dec 31, 2025
54c6bd8
Make CLI --clear-output format-aware with --clear-html backward compat
cboos Dec 31, 2025
6b82fda
Add 'm' shortcut to TUI for opening Markdown session files
cboos Dec 31, 2025
86a81de
Add embedded Markdown viewer to TUI (press 'v')
cboos Dec 31, 2025
9b03632
Add on-demand session file generation to TUI (h/m/v shortcuts)
cboos Dec 31, 2025
14da498
Skip HTML generation in TUI mode (use on-demand via h/m/v)
cboos Dec 31, 2025
bffd4fc
Use MarkdownViewer with ToC in embedded viewer (v shortcut)
cboos Dec 31, 2025
3b94efb
Customize MarkdownViewer ToC: 1/3 width, expand 3 levels
cboos Dec 31, 2025
44a1370
Extract shared regex patterns in snapshot serializers
cboos Dec 31, 2025
d882876
Remove roman numeral prefixes from MarkdownViewer ToC
cboos Dec 31, 2025
e7455d4
Fix ToC label cleaning to handle emoji prefixes
cboos Dec 31, 2025
5b224a1
Add excerpts to sidechain titles and simplify ToC labels
cboos Dec 31, 2025
b265688
Add hidden H/M/V shortcuts for force-regenerate in TUI
cboos Dec 31, 2025
80a69cd
Fix type checker errors in tui.py
cboos Dec 31, 2025
6e1e4e1
Document Markdown export and TUI shortcuts in README
cboos Dec 31, 2025
75ac3f6
Hoist strip_error_tags import to module scope in test file
cboos Dec 31, 2025
4849e2d
Unify file extension computation using get_file_extension()
cboos Dec 31, 2025
c8f7e87
Simplify output_dir assignment after assert
cboos Dec 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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 <sessionId>`
- `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 <sessionId>`
- `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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this true? Which is the default?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean, for the Image Support? Default is embedded base64 as before for HTML, but that's quite annoying for Markdown, so there the default is 'referenced' (command-line help should tell about these defaults).


## Installation

Install using pip:
Expand Down
97 changes: 52 additions & 45 deletions claude_code_log/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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..."
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
)
Expand Down
4 changes: 2 additions & 2 deletions claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 17 additions & 11 deletions claude_code_log/image_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import base64
import binascii
from pathlib import Path
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading