Skip to content

Contributing to Claude Code Log

This guide covers development setup, testing, architecture, and release processes for contributors.

Prerequisites

  • Python 3.10+
  • uv package manager

Getting Started

git clone https://github.com/daaain/claude-code-log.git
cd claude-code-log
uv sync

File Structure

claude_code_log/
├── cli.py              # Command-line interface with project discovery
├── tui.py              # Interactive Terminal User Interface (Textual)
├── parser.py           # Data extraction and parsing from JSONL files
├── renderer.py         # Format-neutral message processing and tree building
├── renderer_timings.py # Performance timing instrumentation
├── converter.py        # High-level conversion orchestration
├── models.py           # Pydantic models for transcript data structures
├── cache.py            # Cache management for performance optimization
├── factories/          # Transcript entry to MessageContent transformation
│   ├── meta_factory.py
│   ├── user_factory.py
│   ├── assistant_factory.py
│   ├── tool_factory.py
│   └── system_factory.py
├── lanes.py            # Branch lanes (sub-agents, forks) for the minimal theme
├── html/               # HTML-specific rendering
│   ├── renderer.py
│   ├── minimal_theme.py  # Minimal theme helpers (gutter, call line, lane attributes)
│   ├── user_formatters.py
│   ├── assistant_formatters.py
│   ├── system_formatters.py
│   ├── tool_formatters.py
│   ├── utils.py
│   └── templates/      # Jinja2 HTML templates
│       ├── transcript.html
│       ├── index.html
│       └── components/
│           ├── timeline.html
│           └── minimal/  # The minimal theme's CSS and JS (incl. the DAG engine)
└── markdown/           # Markdown output rendering
    └── renderer.py

scripts/                # Development utilities
test/test_data/         # Representative JSONL samples
dev-docs/               # Architecture / dev documentation (start in application_model.md)
docs/                   # User-facing operations docs
work/                   # Plans, TODOs, in-flight design docs

Development Setup

The project uses:

  • Python 3.10+ with uv package management
  • Click for CLI interface
  • Textual for Terminal User Interface
  • Pydantic for data modeling and validation
  • Jinja2 for HTML template rendering
  • wenmode for Markdown rendering
  • dateparser for natural language date parsing

Dependency Management

# Add a new dependency
uv add textual

# Remove a dependency
uv remove textual

# Sync dependencies
uv sync

Testing

The project uses a categorized test system to avoid async event loop conflicts.

Test Categories

  • Unit Tests (no mark): Fast, standalone tests
  • TUI Tests (@pytest.mark.tui): Textual-based TUI tests
  • Browser Tests (@pytest.mark.browser): Playwright-based browser tests
  • Snapshot Tests: HTML regression tests using syrupy

Running Tests

# Unit tests only (fast, recommended for development)
just test
# or: uv run pytest -m "not (tui or browser)" -v

# TUI tests (isolated event loop)
just test-tui

# Browser tests (requires Chromium)
just test-browser

# All tests in sequence
just test-all

# Tests with coverage
just test-cov

Snapshot Testing

Snapshot tests detect unintended HTML output changes using syrupy:

# Run snapshot tests (parallel mode is fine for read-only runs)
uv run pytest test/test_snapshot_html.py -v

# Update snapshots after intentional HTML changes
# IMPORTANT: run --snapshot-update with -n0 (see warning below)
uv run pytest test/test_snapshot_html.py -n0 --snapshot-update

--snapshot-update must run serially (-n0). Syrupy and pytest-xdist race when writing the shared .ambr files in parallel: a raced update can silently truncate a file, or make an untouched fixture appear to regenerate with structure it never had, and either result can still pass on the next read. Running serially with a purged __pycache__ avoids both. Because pyproject.toml defaults to -n auto, a conftest.py guard (pytest_configure) fails fast when --snapshot-update is combined with more than one xdist worker. Ordinary parallel runs (no update) are unaffected.

Recognising the race in a diff. The guard prevents the mistake going forward, but you may still meet a suspicious .ambr diff — reviewing a PR that carries snapshot changes, or reading a historical diff from before the guard existed. The rule of thumb:

A negative in an .ambr diff is a signal to investigate, not a verdict. A purely additive regeneration is +N/-0, so deletions mean one of three things: an intentional content change you can name, a benign realignment of shared boilerplate, or the race.

To tell which, check at the block level, not the raw git diff: compare the set of snapshot names (a race removes or rewrites blocks you didn't touch) and diff each block's content. Inserting a snapshot or changing embedded CSS realigns shared boilerplate and can show hundreds of "deletions" with zero content lost — that is the benign case, and it is the one a raw -N most often turns out to be. A real -275 was exactly this: block-level inspection found one snapshot added, none removed or renamed, and seven blocks each +3 for a single white-space: pre-wrap rule, with zero content deleted. Confirm either way with a read-only -n0 run after purging stale bytecode — if every snapshot passes, the committed file matches what the code renders and is not a raced artifact:

find . -name __pycache__ -type d -prune -exec rm -rf {} +
uv run pytest test/test_snapshot_html.py -n0
# all pass → the committed .ambr matches the render (not a raced file)

When you do intend to regenerate, use the recipe — it purges stale bytecode, regenerates serially, and then re-runs read-only to prove the file it just wrote is what the code renders:

just update-snapshot

A purely additive result (+N/-0, e.g. "8 snapshots passed. 1 snapshot generated.") is the healthy signature. The recipe exists because doing only part of this by hand is what produced both incidents above; reach for a bare pytest --snapshot-update only if you have a reason to, and keep the -n0.

When snapshot tests fail: 1. Review the diff to verify changes are intentional 2. If intentional, run --snapshot-update (serially) to accept new output 3. If unintentional, fix your code and re-run tests

Test Prerequisites

Browser tests require Chromium:

uv run playwright install chromium

Why Test Categories?

The test suite is categorized because different async frameworks conflict:

  • TUI tests use Textual's async event loop (run_test())
  • Browser tests use Playwright's internal asyncio
  • pytest-asyncio manages async test execution

Running all tests together can cause "RuntimeError: This event loop is already running". The categorization ensures reliable test execution.

Test Coverage

# Run with coverage
just test-cov

# Or manually:
uv run pytest --cov=claude_code_log --cov-report=html --cov-report=term

HTML coverage reports are generated in htmlcov/index.html.

Testing Resources

  • See test/README.md for comprehensive testing documentation
  • Visual Style Guide: uv run python scripts/generate_style_guide.py
  • Test data in test/test_data/

Code Quality

# Format code
ruff format

# Lint and fix
ruff check --fix

# Type checking
uv run pyright
uv run ty check

# Lint the templates' JavaScript (installs the optional `js` group)
just lint-js

# Format JS, CSS, JSON and YAML, or only check (same group)
just fmt-js
just fmt-check

The HTML pages carry a fair amount of JavaScript: the files under claude_code_log/html/templates/components/, plus <script> blocks inline in the templates. just lint-js checks both with oxlint, a Rust linter that uses ESLint's rule names. It is installed from PyPI by the optional js dependency group (no Node needed), and that group stays out of the default install.

Neither kind of JS can be linted where it sits, because of the Jinja in it. So scripts/lint_js.py lints a staged copy: - Component files are copied with their {% include %} lines turned into comments, so a finding's file:line is the real one. - Inline scripts are taken from pages rendered in both themes, with each component include replaced by a placeholder. They are reported as rendered/<page>-<theme>/script-<n>.js; just lint-js --keep keeps the staged copy so you can open one.

The rules are oxlint's default correctness set, adjusted in .oxlintrc.json. Any warning fails the run, so just lint-js is part of just ci, and CI runs it once (on the primary job). A finding that is a false positive gets a // oxlint-disable-next-line <rule> comment saying why. This happens, for example, with a function the template calls from another script, or only in the other theme.

The minimal theme's dark syntax-highlighting sheet (claude_code_log/html/templates/components/minimal/pygments_dark.css) is generated — don't edit it by hand. After a Pygments upgrade or a change to pygments_styles.css, regenerate it with uv run python scripts/generate_minimal_pygments_css.py; a unit test fails while the committed file differs from the script's output.

Formatting JS, CSS, JSON and YAML

oxfmt, oxlint's sibling, formats the JavaScript, CSS, JSON and YAML; it comes from the same js group, pinned to an exact version because its output changes between releases. .oxfmtrc.json holds the options, and its ignorePatterns leave out, each with its reason: Markdown and TOML, data and generated files, and the two Pygments sheets, which scripts/generate_minimal_pygments_css.py reads line by line.

A few component files hold Jinja (an {% include %}, a {# comment #}), which oxfmt cannot parse. just fmt-js formats them through scripts/fmt_templates.py, which masks each Jinja construct as a /* jinja:N */ comment, formats the masked copy, and puts the constructs back verbatim. Each construct must sit on lines of its own; the script refuses anything else. Such files are found by their content, but they must also be listed in ignorePatterns, which a unit test checks.

just fmt-check is part of just ci, and CI runs it once (on the primary job), so a change that isn't formatted fails; run just fmt-js before committing. The commit that first formatted the tree is listed in .git-blame-ignore-revs; to have git blame look past it, run once:

git config blame.ignoreRevsFile .git-blame-ignore-revs

GitHub's blame view reads the file by itself.

When upgrading oxfmt, look at what a run covers as well as whether it passes: a release can start formatting a new language (Markdown arrived after 0.69), and then a bare run rewrites files nobody chose to format.

Whitespace

An .editorconfig at the repo root defines the baseline — UTF-8, LF line endings, a final newline, and trimmed trailing whitespace. Most editors honour it automatically; please keep it observed. Two paths deliberately opt out of trailing-whitespace trimming: Markdown (a line ending in two spaces is a hard break) and syrupy .ambr snapshots (the serializer indents blank lines, so regenerate them rather than trimming by hand).

Performance Profiling

Enable timing instrumentation to identify bottlenecks:

CLAUDE_CODE_LOG_DEBUG_TIMING=1 claude-code-log path/to/file.jsonl

This outputs detailed timing for each rendering phase, plus hit rates for the render memo caches. The timing module is in claude_code_log/renderer_timings.py.

Pygments highlighting and Markdown rendering are memoized because every message is formatted twice per run (once for its combined page, once for its session file) — see claude_code_log/render_cache.py and dev-docs/application_model.md § 2.9. Set CLAUDE_CODE_LOG_RENDER_CACHE_MB=0 to disable memoization when bisecting a rendering difference; any other value sets the per-cache byte budget in MB (default 192).

Above the leaf memo, a per-conversion fragment store (claude_code_log/fragment_store.py) reuses each message's complete formatted fragment between the combined-page and per-session passes. Set CLAUDE_CODE_LOG_FRAGMENT_STORE=0 to disable it when bisecting. The store is a RAM-for-CPU trade (~+0.35× the project's transcript bytes at peak, measured), so a memory valve skips it automatically when available memory is under ~2.4× those bytes — the conversion then runs store-less at its pre-store footprint; an explicit =1 forces the store past the valve. See dev-docs/application_model.md § 2.9 for its correctness guards.

A project's own pages and session files are additionally rendered in parallel worker processes, on by default at the CPU count. Set CLAUDE_CODE_LOG_RENDER_JOBS=1 (or off) to disable it, or an integer to pin a worker count. It earns its keep on the runs that matter — an incremental run over a real archive measured 93.2s → 34.6s on 16 cores — at the cost of more total CPU, since each worker starts with a cold memo cache. Workers are fed, not self-loading: each unit crosses the process boundary carrying its own entry slice and (for session files) its slice of the fragment store, so workers verify-and-reuse formatted fragments instead of re-formatting, and no worker loads the project's transcript. Small projects are excluded outright, and the worker count is capped against available memory (the parent is charged its measured master-list footprint, each fed worker only its measured slice-holding cost): on a small machine or a large archive it degrades to serial rather than swapping. See dev-docs/application_model.md § 2.10 for the measurements.

Orthogonal to both, a conversion whose cache is fresh and whose combined output is current regenerates stale session files from those sessions' own JSONL plus a persisted cross-session sidecar, instead of loading the whole project (session-scoped incremental rendering — see dev-docs/application_model.md § 2.12). Set CLAUDE_CODE_LOG_SESSION_SCOPED=0 to force the full-load path when bisecting a rendering difference.

When the combined output is stale, a paginated project converts page-by-page instead of loading whole when either of two gates opens: on a memory-tight machine (available memory under ~2.4x the project's transcript bytes — the same knee where the fragment store declines) it always streams, and on a roomy machine it streams when at most a third of the planned pages need work — the daily-run shape, where a couple of page loads beat the whole-project load the full path would pay (a dense rebuild still declines to the full load + fan-out, which wins it on wall time). Pages are planned from cached session data, and each page's sessions are loaded, rendered (page + stale session files, with a per-page fragment store) and dropped in turn, so peak residency is one page rather than the project — an archive's size stops being the bound; the largest page's source files become it. CLAUDE_CODE_LOG_STREAMING=1 bypasses both gates (it streams on a roomy machine and on a dense rebuild), but not the structural preconditions: the conversion still has to be the paginated HTML path with every page's sessions resolvable to their source files, and the machine still has to hold the largest page. =0 disables streaming for bisecting. See dev-docs/application_model.md § 2.13.

The cache refresh itself is also incremental: when source files changed over a populated cache, session rows, project aggregates, and the cross-session sidecar are recomputed from the modified files' bounded coupling closure (dedup partners, attachment owners, junction targets) instead of loading the whole project — the refresh's residency scales with what changed, not with the archive. Anything hairy (deleted files, rewritten history, closures past a third of the project, cross-boundary token attribution) declines to the unchanged full-load refresh. Set CLAUDE_CODE_LOG_INCREMENTAL_CACHE=0 to force the full refresh when bisecting. See dev-docs/application_model.md § 2.14.

That refresh parses each modified file from source, and the loads that follow it — the closure load and the session-scoped render — used to rebuild those same entries from the rows it had just written, twice. A per-conversion parsed-entry store (claude_code_log/entry_store.py) serves the refresh's list to both instead, taking a watch tick on an 803MB archive from 1.03s to 0.72s. Only the incremental refresh fills it, and only with the files that changed, so a cold conversion and the streaming path (whose bounded residency depends on dropping each page) carry no extra memory.

Held across ticks — which watch does, owning one for the life of the loop — the same store also resumes. It pins its entries to a byte offset plus a hash of the bytes below it, so a tick hashes that prefix (32ms over 39.7MB, against 143ms to re-parse it), reads only what was appended, and appends just the new cache rows rather than rewriting the file's. That takes the same tick to 0.26s. It applies only where the rows are provably the file's own lines: a trunk's rows carry its subagents' spliced transcripts, so a running subagent grows a block mid-sequence and those files take the unchanged full rewrite.

Set CLAUDE_CODE_LOG_ENTRY_STORE=0 to disable all of it when bisecting; a per-file memory valve declines to hold a file when available memory is under ~6× its bytes, and an explicit =1 overrides that valve. See dev-docs/application_model.md § 2.16.

To re-measure on your own hardware (core count changes the answer for the fan-out), point the benchmark at a real project:

uv run python scripts/bench_render.py ~/.claude/projects/<project>

It copies the project to scratch space, warms the cache, then times every combination of the knobs — including, on a paginated project, streaming rows and an incremental scenario, with a peak-RSS column — and hashes the output of each, so it doubles as an equivalence check across far more real data than the test fixtures cover.

Diagnosing Hangs

If claude-code-log appears stuck (100% CPU, no output), send SIGUSR1 to print the live Python stack to stderr without killing the process:

# In another terminal
kill -USR1 $(pgrep -f claude-code-log | head -1)

The handler is installed in cli.py via faulthandler.register(SIGUSR1). POSIX-only; no-op on Windows. Unlike py-spy, it needs no root and no extra install.

Documentation Site

The project publishes a documentation site to GitHub Pages, built with MkDocs and the Material theme. The site configuration is mkdocs.yml; pages live under docs/.

# Install docs dependencies
uv sync --group docs

# Live-reload preview at http://127.0.0.1:8000
just docs-serve

# Strict build (fails on broken links/nav — same as CI)
just docs-build

Key points:

  • CLI reference (docs/reference/cli.md) is rendered live from the Click command via the mkdocs-click plugin — no manual upkeep.
  • TUI reference (reference/tui.md) is generated at build time by docs/gen_pages.py (a mkdocs-gen-files script): it introspects the Textual BINDINGS for the keybindings tables (scripts/generate_tui_docs.py) and captures SVG screenshots of the running TUI (scripts/generate_tui_screenshots.py). Both scripts are runnable standalone.
  • Example output (example.md + examples/transcript.html, and the same transcript in the minimal theme as examples/transcript-minimal.html) is rendered at build time from a bundled sample project (scripts/generate_example_output.py, also just example) — no private data or release asset involved. Generation is fault-tolerant so a render hiccup can't block the build.
  • Development section surfaces dev-docs/ (symlinked as docs/development). CONTRIBUTING.md and CHANGELOG.md are symlinked in as docs/contributing.md and docs/changelog.md. A build hook (docs/hooks.py) rewrites links to repo source files (e.g. ../claude_code_log/cli.py) into GitHub URLs so the strict build stays green.
  • Deployment is automated by .github/workflows/docs.yml: PRs run a strict build; pushes to main deploy to Pages. The repo's Settings → Pages → Source must be set to GitHub Actions (one-time).

Demo videos

The README's overview video and the docs site's Demo videos page are recorded by a script, so they can be re-recorded whenever the UI changes:

just demos                     # record every scene into .demos/ (~6 min)
just demos --only branches     # re-record one scene (chapter and beat)
just demos --list              # the scenes
just demos --publish           # record, then update docs/assets/demos/ + docs/demos.md

scripts/demos/record.py drives Chromium through each scene with Playwright: captions, title cards, a cursor and fades are drawn by an overlay injected into the page (director.py), frames come from a Chrome DevTools screencast, and ffmpeg (from the demos dependency group's imageio-ffmpeg wheel) encodes the clips and cross-fades them into the supercut and the walkthrough. The data is synthetic and committed (scripts/demos/demo_data.py, plus the live chapter's test/dag_live_fixture.py), served by the real claude-code-log serve, so the videos re-record identically anywhere. To show a new feature, add a @scene and list it in CHAPTERS or SUPERCUT.

The README embeds the supercut as a GitHub upload: after --publish, drag docs/assets/demos/supercut.mp4 into the README editor on GitHub and replace the old attachment link.

Architecture

Start with dev-docs/application_model.md for the system overview (subsystems, data lifecycle, glossary). For the rendering pipeline specifically, see dev-docs/rendering-architecture.md.

Themes

HTML output has two themes, classic (the default) and minimal, selected with --theme or CLAUDE_CODE_LOG_THEME; default resolves through utils.DEFAULT_THEME, so changing the default is a one-line change. The user guide is docs/themes.md; the as-built reference for the minimal theme — where its parts live, the branch (DAG) engine, previews, live updates, performance — starts at dev-docs/minimal-theme.md. Two rules hold for every change:

  • Classic message markup stays byte-identical. Minimal-only template additions are glued inline ({% if minimal %}…{% endif %}, no new lines outside the conditional) and minimal-only formatter behaviour is behind normalize_theme(self.theme) == "minimal", so a minimal change never moves a classic message's markup. The embedded CSS and JS the themes share (filter, search, timeline, live update) may change, but only deliberately: say so in the commit, and check at block level (after just update-snapshot) that every changed classic line is in <style>/<script>. The minimal theme's introduction did exactly this once — its shared filter/search/live-update hooks grew classic pages by ~10KB and fixed three timeline behaviours (the Tool toggle governing both tool groups, hiding items whose card is filtered out, getBoundingClientRect scrolling) — with no message markup changed.
  • Timeline and filter parity in every branch mode. The browser sweep in test/test_minimal_parity_browser.py checks every filter toggle × fold depth × branch mode with the timeline open, search and live updates included; extend it when adding a message type.

To compare how long a page takes to load and lay out in each theme, run uv run python scripts/bench_page_load.py (the minimal theme lays the transcript out once, as the DAG grid — dev-docs/minimal-theme.md § 5).

Data Flow Overview

JSONL File
    ↓ (parser.py)
list[TranscriptEntry]
    ↓ (factories/)
list[TemplateMessage] with MessageContent
    ↓ (renderer.py)
Tree of TemplateMessage (roots with children)
    ↓ (html/renderer.py or markdown/renderer.py)
Final output (HTML or Markdown)

Data Models

The application uses Pydantic models to parse and validate transcript JSON data:

  • TranscriptEntry: Union of User, Assistant, Summary, System, QueueOperation entries
  • UsageInfo: Token usage tracking (input/output tokens, cache tokens)
  • ContentItem: Union of Text, ToolUse, ToolResult, Thinking, Image content

Template System

Uses Jinja2 templates for HTML generation:

  • Session Navigation: Table of contents with timestamp ranges and token summaries
  • Message Rendering: Handles different content types with appropriate formatting
  • Token Display: Shows usage for individual messages and session totals

Timeline Component

The interactive timeline is implemented in JavaScript within claude_code_log/html/templates/components/timeline.html. When adding new message types or modifying CSS class generation, ensure the timeline's message type detection logic is updated accordingly.

Cache System

The tool implements a SQLite-based caching system for performance:

  • Location: claude-code-log-cache.db in the projects directory (or set CLAUDE_CODE_LOG_CACHE_PATH env var)
  • Contents: Pre-parsed session metadata (IDs, summaries, timestamps, token usage)
  • Invalidation: Automatic detection based on file modification times
  • Performance: 10-100x faster loading for large projects

The cache automatically rebuilds when source files change or cache schema version changes.

Release Process

Releases are tag-driven: pushing a version tag runs .github/workflows/release.yml, which publishes to PyPI only after a maintainer approves it.

Cutting a release

# On main, with a clean tree: bump version, update uv.lock and CHANGELOG.md,
# commit and tag — all local (patch/minor/major, or an exact version)
just release-prep minor

# Check the notes the GitHub Release will carry, and the commit
just release-preview
git show --stat HEAD

# Push main and the tag together (--atomic), which starts the workflow
just release-push

Until the push, git tag -d X.Y.Z && git reset --hard HEAD~1 undoes it.

What the workflow does

  1. ci — refuses a tag whose commit isn't on main, then waits for the CI run (ci.yml) that the same push started on the release commit, and fails the release if it fails. Nothing is re-run.
  2. build — in parallel, checks that the tag, [project].version, uv.lock's own claude-code-log entry and a ## [X.Y.Z] CHANGELOG section all agree, then runs uv build and extracts the release notes (scripts/release_notes.py).
  3. publish — once both pass, waits in the pypi environment for a required reviewer to approve the deployment in the run's page on GitHub, then uv publishes.
  4. github-release — once PyPI has it, creates the GitHub Release from the notes, with the same wheel and sdist attached.

Publishing uses PyPI trusted publishing: PyPI trusts this workflow and the pypi environment, so no API token exists anywhere. A version, once published, is burned — PyPI refuses re-uploads even after a delete, so a botched release is fixed by releasing the next number.

just publish is the manual escape hatch for when GitHub is down; it needs PyPI credentials (e.g. UV_PUBLISH_TOKEN) locally.

Who can release

Pushing a tag needs write access, and on this personally-owned repo every collaborator has it — so collaborators are the maintainers who can start a release. Publishing additionally needs one of the pypi environment's required reviewers to approve. Before adding a collaborator who shouldn't release, note that write access is all-or-nothing here: per-role tag restrictions need an organisation-owned repo.

One-time setup

  • PyPI → project settings → Publishing → add a GitHub publisher: owner daaain, repo claude-code-log, workflow release.yml, environment pypi.
  • GitHub → Settings → Environments → pypi: add the maintainers as required reviewers (any one approval releases), and limit deployments to tags matching [0-9]*.[0-9]*.[0-9]*.
  • GitHub → Settings → Rules → Rulesets: the "release tags are immutable" tag ruleset blocks deleting, moving or force-pushing refs/tags/[0-9]*, so a published version's tag always names what was released. Releasing a botched version again means the next number, which PyPI requires anyway.