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¶
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-updatemust run serially (-n0). Syrupy and pytest-xdist race when writing the shared.ambrfiles 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. Becausepyproject.tomldefaults to-n auto, aconftest.pyguard (pytest_configure) fails fast when--snapshot-updateis 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
.ambrdiff 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:
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:
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:
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:
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:
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:
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 themkdocs-clickplugin — no manual upkeep. - TUI reference (
reference/tui.md) is generated at build time bydocs/gen_pages.py(amkdocs-gen-filesscript): it introspects the TextualBINDINGSfor 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 asexamples/transcript-minimal.html) is rendered at build time from a bundled sample project (scripts/generate_example_output.py, alsojust 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 asdocs/development).CONTRIBUTING.mdandCHANGELOG.mdare symlinked in asdocs/contributing.mdanddocs/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 tomaindeploy 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 behindnormalize_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 (afterjust 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,getBoundingClientRectscrolling) — with no message markup changed. - Timeline and filter parity in every branch mode. The browser
sweep in
test/test_minimal_parity_browser.pychecks 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.dbin the projects directory (or setCLAUDE_CODE_LOG_CACHE_PATHenv 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¶
- 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. - build — in parallel, checks that the tag,
[project].version,uv.lock's ownclaude-code-logentry and a## [X.Y.Z]CHANGELOG section all agree, then runsuv buildand extracts the release notes (scripts/release_notes.py). - publish — once both pass, waits in the
pypienvironment for a required reviewer to approve the deployment in the run's page on GitHub, thenuv publishes. - 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, repoclaude-code-log, workflowrelease.yml, environmentpypi. - 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.