diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4e46b4..d182fd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,9 +7,10 @@ permissions: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 @@ -23,5 +24,7 @@ jobs: run: | pip install -e . --group dev - name: Run tests + env: + PYTHONUTF8: "1" run: | python -m pytest diff --git a/.gitignore b/.gitignore index 6a4dae1..a4a9ce6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store __pycache__ +uv.lock +.playwright-mcp/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1965014 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,161 @@ +# Development Guide + +This guide covers everything needed to contribute to claude-code-transcripts. + +## Quick Start + +```bash +# Clone and setup +git clone https://github.com/simonw/claude-code-transcripts.git +cd claude-code-transcripts + +# Install uv if not already installed +# See: https://docs.astral.sh/uv/ + +# Install dependencies +uv sync --group dev + +# Run tests +uv run pytest + +# Run the development version +uv run claude-code-transcripts --help +``` + +## Project Structure + +``` +claude-code-transcripts/ +├── src/claude_code_transcripts/ +│ ├── __init__.py # Main implementation +│ └── templates/ # Jinja2 templates +│ ├── macros.html # Reusable macros +│ ├── page.html # Page template +│ ├── index.html # Index template +│ ├── base.html # Base template +│ └── search.js # Client-side search +├── tests/ +│ ├── test_generate_html.py # Main test suite +│ ├── test_all.py # Batch command tests +│ ├── sample_session.json # Test fixture (JSON) +│ ├── sample_session.jsonl # Test fixture (JSONL) +│ └── __snapshots__/ # Snapshot test outputs +├── TASKS.md # Implementation roadmap +├── AGENTS.md # This file +└── pyproject.toml # Package configuration +``` + +## Running Tests + +```bash +# Run all tests +uv run pytest + +# Run specific test file +uv run pytest tests/test_generate_html.py + +# Run specific test class +uv run pytest tests/test_generate_html.py::TestRenderContentBlock + +# Run specific test +uv run pytest tests/test_generate_html.py::TestRenderContentBlock::test_text_block -v + +# Run with verbose output +uv run pytest -v + +# Run with stdout capture disabled (for debugging) +uv run pytest -s +``` + +## Code Formatting + +Format code with Black before committing: + +```bash +uv run black . +``` + +Check formatting without making changes: + +```bash +uv run black . --check +``` + +## Test-Driven Development (TDD) + +Always practice TDD: write a failing test, watch it fail, then make it pass. + +1. Write a failing test for your change +2. Run tests to confirm it fails: `uv run pytest` +3. Implement the feature to make the test pass +4. Format your code: `uv run black .` +5. Run all tests to ensure nothing broke +6. Commit with a descriptive message + +## Snapshot Testing + +This project uses `syrupy` for snapshot testing. Snapshots are stored in `tests/__snapshots__/`. + +Update snapshots when intentionally changing output: + +```bash +uv run pytest --snapshot-update +``` + +## Making Changes + +### Commit Guidelines + +Commit early and often. Each commit should bundle: +- The test +- The implementation +- Documentation changes (if applicable) + +Example commit message: +``` +Add support for filtering sessions by date + +- Add --since and --until flags to local command +- Filter sessions by modification time +- Add tests for date filtering +``` + +### Before Submitting a PR + +1. All tests pass: `uv run pytest` +2. Code is formatted: `uv run black .` +3. Documentation updated if adding user-facing features +4. TASKS.md updated if completing a tracked task + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `src/claude_code_transcripts/__init__.py` | Main implementation (~1300 lines) | +| `src/claude_code_transcripts/templates/macros.html` | Jinja2 macros for rendering | +| `tests/test_generate_html.py` | Main test suite | +| `tests/sample_session.json` | Test fixture data | +| `TASKS.md` | Implementation roadmap and status | + +## Debugging Tips + +```bash +# See full assertion output +uv run pytest -vv + +# Stop on first failure +uv run pytest -x + +# Run only failed tests from last run +uv run pytest --lf + +# Run tests matching a pattern +uv run pytest -k "test_ansi" +``` + +## Architecture Notes + +- CSS and JavaScript are embedded as string constants in `__init__.py` +- Templates use Jinja2 with autoescape enabled +- The `_macros` module exposes macros from `macros.html` +- Tool rendering follows the pattern: Python function → Jinja2 macro → HTML diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 9ff7230..46effea 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,205 @@ -# claude-code-publish +# claude-code-transcripts -[![PyPI](https://img.shields.io/pypi/v/claude-code-publish.svg)](https://pypi.org/project/claude-code-publish/) -[![Changelog](https://img.shields.io/github/v/release/simonw/claude-code-publish?include_prereleases&label=changelog)](https://github.com/simonw/claude-code-publish/releases) -[![Tests](https://github.com/simonw/claude-code-publish/workflows/Test/badge.svg)](https://github.com/simonw/claude-code-publish/actions?query=workflow%3ATest) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/claude-code-publish/blob/main/LICENSE) +[![PyPI](https://img.shields.io/pypi/v/claude-code-transcripts.svg)](https://pypi.org/project/claude-code-transcripts/) +[![Changelog](https://img.shields.io/github/v/release/simonw/claude-code-transcripts?include_prereleases&label=changelog)](https://github.com/simonw/claude-code-transcripts/releases) +[![Tests](https://github.com/simonw/claude-code-transcripts/workflows/Test/badge.svg)](https://github.com/simonw/claude-code-transcripts/actions?query=workflow%3ATest) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/claude-code-transcripts/blob/main/LICENSE) -Convert Claude Code `session.json` files to clean, mobile-friendly HTML pages with pagination. +Convert Claude Code session files (JSON or JSONL) to clean, mobile-friendly HTML pages with pagination. [Example transcript](https://static.simonwillison.net/static/2025/claude-code-microjs/index.html) produced using this tool. +Read [A new way to extract detailed transcripts from Claude Code](https://simonwillison.net/2025/Dec/25/claude-code-transcripts/) for background on this project. ## Installation Install this tool using `uv`: ```bash -uv tool install claude-code-publish +uv tool install claude-code-transcripts ``` Or run it without installing: ```bash -uvx claude-code-publish --help +uvx claude-code-transcripts --help ``` ## Usage -When using [Claude Code for web](https://claude.ai/code) you can export your session as a `session.json` file using the `teleport` command (and then hunting around on disk). +This tool converts Claude Code session files into browseable multi-page HTML transcripts. -This tool converts that JSON into a browseable multi-page HTML transcript. +There are four commands available: + +- `local` (default) - select from local Claude Code sessions stored in `~/.claude/projects` +- `web` - select from web sessions via the Claude API +- `json` - convert a specific JSON or JSONL session file +- `all` - convert all local sessions to a browsable HTML archive + +The quickest way to view a recent local session: ```bash -claude-code-publish session.json -o output-directory/ +claude-code-transcripts ``` -This will generate: +This shows an interactive picker to select a session, generates HTML, and opens it in your default browser. + +### Output options + +All commands support these options: + +- `-o, --output DIRECTORY` - output directory (default: writes to temp dir and opens browser) +- `-a, --output-auto` - auto-name output subdirectory based on session ID or filename +- `--repo OWNER/NAME` - GitHub repo for commit links (auto-detected from git push output if not specified) +- `--open` - open the generated `index.html` in your default browser (default if no `-o` specified) +- `--gist` - upload the generated HTML files to a GitHub Gist and output a preview URL +- `--json` - include the original session file in the output directory + +The generated output includes: - `index.html` - an index page with a timeline of prompts and commits - `page-001.html`, `page-002.html`, etc. - paginated transcript pages -### Options +### Local sessions -- `-o, --output DIRECTORY` - output directory (default: current directory) -- `--repo OWNER/NAME` - GitHub repo for commit links (auto-detected from git push output if not specified) +Local Claude Code sessions are stored as JSONL files in `~/.claude/projects`. Run with no arguments to select from recent sessions: + +```bash +claude-code-transcripts +# or explicitly: +claude-code-transcripts local +``` + +Use `--limit` to control how many sessions are shown (default: 10): + +```bash +claude-code-transcripts local --limit 20 +``` + +### Web sessions + +Import sessions directly from the Claude API: + +```bash +# Interactive session picker +claude-code-transcripts web + +# Import a specific session by ID +claude-code-transcripts web SESSION_ID + +# Import and publish to gist +claude-code-transcripts web SESSION_ID --gist +``` + +On macOS, API credentials are automatically retrieved from your keychain (requires being logged into Claude Code). On other platforms, provide `--token` and `--org-uuid` manually. + +### Publishing to GitHub Gist + +Use the `--gist` option to automatically upload your transcript to a GitHub Gist and get a shareable preview URL: + +```bash +claude-code-transcripts --gist +claude-code-transcripts web --gist +claude-code-transcripts json session.json --gist +``` + +This will output something like: +``` +Gist: https://gist.github.com/username/abc123def456 +Preview: https://gistpreview.github.io/?abc123def456/index.html +Files: /var/folders/.../session-id +``` + +The preview URL uses [gistpreview.github.io](https://gistpreview.github.io/) to render your HTML gist. The tool automatically injects JavaScript to fix relative links when served through gistpreview. + +Combine with `-o` to keep a local copy: + +```bash +claude-code-transcripts json session.json -o ./my-transcript --gist +``` + +**Requirements:** The `--gist` option requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated (`gh auth login`). + +### Auto-naming output directories + +Use `-a/--output-auto` to automatically create a subdirectory named after the session: + +```bash +# Creates ./session_ABC123/ subdirectory +claude-code-transcripts web SESSION_ABC123 -a + +# Creates ./transcripts/session_ABC123/ subdirectory +claude-code-transcripts web SESSION_ABC123 -o ./transcripts -a +``` + +### Including the source file + +Use the `--json` option to include the original session file in the output directory: + +```bash +claude-code-transcripts json session.json -o ./my-transcript --json +``` + +This will output: +``` +JSON: ./my-transcript/session_ABC.json (245.3 KB) +``` + +This is useful for archiving the source data alongside the HTML output. + +### Converting from JSON/JSONL files + +Convert a specific session file directly: + +```bash +claude-code-transcripts json session.json -o output-directory/ +claude-code-transcripts json session.jsonl --open +``` + +When using [Claude Code for web](https://claude.ai/code) you can export your session as a `session.json` file using the `teleport` command. + +### Converting all sessions + +Convert all your local Claude Code sessions to a browsable HTML archive: + +```bash +claude-code-transcripts all +``` + +This creates a directory structure with: +- A master index listing all projects +- Per-project pages listing sessions +- Individual session transcripts + +Options: + +- `-s, --source DIRECTORY` - source directory (default: `~/.claude/projects`) +- `-o, --output DIRECTORY` - output directory (default: `./claude-archive`) +- `--include-agents` - include agent session files (excluded by default) +- `--dry-run` - show what would be converted without creating files +- `--open` - open the generated archive in your default browser +- `-q, --quiet` - suppress all output except errors + +Examples: + +```bash +# Preview what would be converted +claude-code-transcripts all --dry-run + +# Convert all sessions and open in browser +claude-code-transcripts all --open + +# Convert to a specific directory +claude-code-transcripts all -o ./my-archive + +# Include agent sessions +claude-code-transcripts all --include-agents +``` ## Development To contribute to this tool, first checkout the code. You can run the tests using `uv run`: ```bash -cd claude-code-publish +cd claude-code-transcripts uv run pytest ``` And run your local development copy of the tool like this: ```bash -uv run claude-code-publish --help +uv run claude-code-transcripts --help ``` diff --git a/pyproject.toml b/pyproject.toml index f9ba0d8..94c4d3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,31 @@ [project] -name = "claude-code-publish" -version = "0.1.0" -description = "Add your description here" +name = "claude-code-transcripts" +version = "0.4" +description = "Convert Claude Code session files to HTML transcripts" readme = "README.md" +license = "Apache-2.0" authors = [ - { name = "Simon Willison", email = "swillison@gmail.com" } + { name = "Simon Willison" } ] requires-python = ">=3.10" -dependencies = ["markdown"] +dependencies = [ + "click", + "click-default-group", + "httpx", + "jinja2", + "markdown", + "pygments>=2.17.0", + "questionary", +] + +[project.urls] +Homepage = "https://github.com/simonw/claude-code-transcripts" +Changelog = "https://github.com/simonw/claude-code-transcripts/releases" +Issues = "https://github.com/simonw/claude-code-transcripts/issues" +CI = "https://github.com/simonw/claude-code-transcripts/actions" [project.scripts] -claude-code-publish = "claude_code_publish:main" +claude-code-transcripts = "claude_code_transcripts:main" [build-system] requires = ["uv_build>=0.9.7,<0.10.0"] @@ -18,6 +33,8 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "black>=24.0.0", "pytest>=9.0.2", + "pytest-httpx>=0.35.0", "syrupy>=5.0.0", ] diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py deleted file mode 100644 index d744510..0000000 --- a/src/claude_code_publish/__init__.py +++ /dev/null @@ -1,720 +0,0 @@ -"""Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination.""" - -import argparse -import json -import html -import re -from pathlib import Path - -import markdown - -# Regex to match git commit output: [branch hash] message -COMMIT_PATTERN = re.compile(r'\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)') - -# Regex to detect GitHub repo from git push output (e.g., github.com/owner/repo/pull/new/branch) -GITHUB_REPO_PATTERN = re.compile(r'github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/new/') - -PROMPTS_PER_PAGE = 5 -LONG_TEXT_THRESHOLD = 1000 # Characters - text blocks longer than this are shown in index - -# Module-level variable for GitHub repo (set by generate_html) -_github_repo = None - - -def detect_github_repo(loglines): - """ - Detect GitHub repo from git push output in tool results. - - Looks for patterns like: - - github.com/owner/repo/pull/new/branch (from git push messages) - - Returns the first detected repo (owner/name) or None. - """ - for entry in loglines: - message = entry.get("message", {}) - content = message.get("content", []) - if not isinstance(content, list): - continue - for block in content: - if not isinstance(block, dict): - continue - if block.get("type") == "tool_result": - result_content = block.get("content", "") - if isinstance(result_content, str): - match = GITHUB_REPO_PATTERN.search(result_content) - if match: - return match.group(1) - return None - - -def format_json(obj): - try: - if isinstance(obj, str): - obj = json.loads(obj) - formatted = json.dumps(obj, indent=2, ensure_ascii=False) - return f'
{html.escape(formatted)}
' - except (json.JSONDecodeError, TypeError): - return f'
{html.escape(str(obj))}
' - - -def render_markdown_text(text): - if not text: - return "" - return markdown.markdown(text, extensions=["fenced_code", "tables"]) - - -def is_json_like(text): - if not text or not isinstance(text, str): - return False - text = text.strip() - return (text.startswith("{") and text.endswith("}")) or (text.startswith("[") and text.endswith("]")) - - -def render_todo_write(tool_input, tool_id): - todos = tool_input.get("todos", []) - if not todos: - return "" - items_html = [] - for todo in todos: - status = todo.get("status", "pending") - content = todo.get("content", "") - if status == "completed": - icon, status_class = "✓", "todo-completed" - elif status == "in_progress": - icon, status_class = "→", "todo-in-progress" - else: - icon, status_class = "○", "todo-pending" - items_html.append(f'
  • {icon}{html.escape(content)}
  • ') - return f'
    Task List
    ' - - -def render_write_tool(tool_input, tool_id): - """Render Write tool calls with file path header and content preview.""" - file_path = tool_input.get("file_path", "Unknown file") - content = tool_input.get("content", "") - # Extract filename from path - filename = file_path.split("/")[-1] if "/" in file_path else file_path - content_preview = html.escape(content) - return f'''
    -
    📝 Write {html.escape(filename)}
    -
    {html.escape(file_path)}
    -
    {content_preview}
    -
    ''' - - -def render_edit_tool(tool_input, tool_id): - """Render Edit tool calls with diff-like old/new display.""" - file_path = tool_input.get("file_path", "Unknown file") - old_string = tool_input.get("old_string", "") - new_string = tool_input.get("new_string", "") - replace_all = tool_input.get("replace_all", False) - # Extract filename from path - filename = file_path.split("/")[-1] if "/" in file_path else file_path - replace_note = ' (replace all)' if replace_all else "" - return f'''
    -
    ✏️ Edit {html.escape(filename)}{replace_note}
    -
    {html.escape(file_path)}
    -
    -
    {html.escape(old_string)}
    -
    +
    {html.escape(new_string)}
    -
    -
    ''' - - -def render_bash_tool(tool_input, tool_id): - """Render Bash tool calls with command as plain text.""" - command = tool_input.get("command", "") - description = tool_input.get("description", "") - desc_html = f'
    {html.escape(description)}
    ' if description else "" - return f'''
    -
    $ Bash
    -{desc_html}
    {html.escape(command)}
    -
    ''' - - -def render_content_block(block): - if not isinstance(block, dict): - return f"

    {html.escape(str(block))}

    " - block_type = block.get("type", "") - if block_type == "thinking": - return f'
    Thinking
    {render_markdown_text(block.get("thinking", ""))}
    ' - elif block_type == "text": - return f'
    {render_markdown_text(block.get("text", ""))}
    ' - elif block_type == "tool_use": - tool_name = block.get("name", "Unknown tool") - tool_input = block.get("input", {}) - tool_id = block.get("id", "") - if tool_name == "TodoWrite": - return render_todo_write(tool_input, tool_id) - if tool_name == "Write": - return render_write_tool(tool_input, tool_id) - if tool_name == "Edit": - return render_edit_tool(tool_input, tool_id) - if tool_name == "Bash": - return render_bash_tool(tool_input, tool_id) - description = tool_input.get("description", "") - desc_html = f'
    {html.escape(description)}
    ' if description else "" - display_input = {k: v for k, v in tool_input.items() if k != "description"} - return f'
    {html.escape(tool_name)}
    {desc_html}
    {format_json(display_input)}
    ' - elif block_type == "tool_result": - content = block.get("content", "") - is_error = block.get("is_error", False) - error_class = " tool-error" if is_error else "" - - # Check for git commits and render with styled cards - if isinstance(content, str): - commits_found = list(COMMIT_PATTERN.finditer(content)) - if commits_found: - # Build commit cards + remaining content - parts = [] - last_end = 0 - for match in commits_found: - # Add any content before this commit - before = content[last_end:match.start()].strip() - if before: - parts.append(f'
    {html.escape(before)}
    ') - - commit_hash = match.group(1) - commit_msg = match.group(2) - if _github_repo: - github_link = f'https://github.com/{_github_repo}/commit/{commit_hash}' - parts.append(f'
    {commit_hash[:7]} {html.escape(commit_msg)}
    ') - else: - parts.append(f'
    {commit_hash[:7]} {html.escape(commit_msg)}
    ') - last_end = match.end() - - # Add any remaining content after last commit - after = content[last_end:].strip() - if after: - parts.append(f'
    {html.escape(after)}
    ') - - content_html = ''.join(parts) - else: - content_html = f"
    {html.escape(content)}
    " - elif isinstance(content, list) or is_json_like(content): - content_html = format_json(content) - else: - content_html = format_json(content) - return f'
    {content_html}
    ' - else: - return format_json(block) - - -def render_user_message_content(message_data): - content = message_data.get("content", "") - if isinstance(content, str): - if is_json_like(content): - return f'
    {format_json(content)}
    ' - return f'
    {render_markdown_text(content)}
    ' - elif isinstance(content, list): - return "".join(render_content_block(block) for block in content) - return f"

    {html.escape(str(content))}

    " - - -def render_assistant_message(message_data): - content = message_data.get("content", []) - if not isinstance(content, list): - return f"

    {html.escape(str(content))}

    " - return "".join(render_content_block(block) for block in content) - - -def make_msg_id(timestamp): - return f"msg-{timestamp.replace(':', '-').replace('.', '-')}" - - -def analyze_conversation(messages): - """Analyze messages in a conversation to extract stats and long texts.""" - tool_counts = {} # tool_name -> count - long_texts = [] - commits = [] # list of (hash, message, timestamp) - - for log_type, message_json, timestamp in messages: - if not message_json: - continue - try: - message_data = json.loads(message_json) - except json.JSONDecodeError: - continue - - content = message_data.get("content", []) - if not isinstance(content, list): - continue - - for block in content: - if not isinstance(block, dict): - continue - block_type = block.get("type", "") - - if block_type == "tool_use": - tool_name = block.get("name", "Unknown") - tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1 - elif block_type == "tool_result": - # Check for git commit output - result_content = block.get("content", "") - if isinstance(result_content, str): - for match in COMMIT_PATTERN.finditer(result_content): - commits.append((match.group(1), match.group(2), timestamp)) - elif block_type == "text": - text = block.get("text", "") - if len(text) >= LONG_TEXT_THRESHOLD: - long_texts.append(text) - - return { - "tool_counts": tool_counts, - "long_texts": long_texts, - "commits": commits, - } - - -def format_tool_stats(tool_counts): - """Format tool counts into a concise summary string.""" - if not tool_counts: - return "" - - # Abbreviate common tool names - abbrev = { - "Bash": "bash", - "Read": "read", - "Write": "write", - "Edit": "edit", - "Glob": "glob", - "Grep": "grep", - "Task": "task", - "TodoWrite": "todo", - "WebFetch": "fetch", - "WebSearch": "search", - } - - parts = [] - for name, count in sorted(tool_counts.items(), key=lambda x: -x[1]): - short_name = abbrev.get(name, name.lower()) - parts.append(f"{count} {short_name}") - - return " · ".join(parts) - - -def is_tool_result_message(message_data): - """Check if a message contains only tool_result blocks.""" - content = message_data.get("content", []) - if not isinstance(content, list): - return False - if not content: - return False - return all( - isinstance(block, dict) and block.get("type") == "tool_result" - for block in content - ) - - -def render_message(log_type, message_json, timestamp): - if not message_json: - return "" - try: - message_data = json.loads(message_json) - except json.JSONDecodeError: - return "" - if log_type == "user": - content_html = render_user_message_content(message_data) - # Check if this is a tool result message - if is_tool_result_message(message_data): - role_class, role_label = "tool-reply", "Tool reply" - else: - role_class, role_label = "user", "User" - elif log_type == "assistant": - content_html = render_assistant_message(message_data) - role_class, role_label = "assistant", "Assistant" - else: - return "" - if not content_html.strip(): - return "" - msg_id = make_msg_id(timestamp) - return f'
    {role_label}
    {content_html}
    ' - - -CSS = ''' -:root { --bg-color: #f5f5f5; --card-bg: #ffffff; --user-bg: #e3f2fd; --user-border: #1976d2; --assistant-bg: #f5f5f5; --assistant-border: #9e9e9e; --thinking-bg: #fff8e1; --thinking-border: #ffc107; --thinking-text: #666; --tool-bg: #f3e5f5; --tool-border: #9c27b0; --tool-result-bg: #e8f5e9; --tool-error-bg: #ffebee; --text-color: #212121; --text-muted: #757575; --code-bg: #263238; --code-text: #aed581; } -* { box-sizing: border-box; } -body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-color); color: var(--text-color); margin: 0; padding: 16px; line-height: 1.6; } -.container { max-width: 800px; margin: 0 auto; } -h1 { font-size: 1.5rem; margin-bottom: 24px; padding-bottom: 8px; border-bottom: 2px solid var(--user-border); } -.message { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } -.message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } -.message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } -.message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } -.tool-reply .role-label { color: #e65100; } -.tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } -.tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } -.message-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; } -.role-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } -.user .role-label { color: var(--user-border); } -time { color: var(--text-muted); font-size: 0.8rem; } -.timestamp-link { color: inherit; text-decoration: none; } -.timestamp-link:hover { text-decoration: underline; } -.message:target { animation: highlight 2s ease-out; } -@keyframes highlight { 0% { background-color: rgba(25, 118, 210, 0.2); } 100% { background-color: transparent; } } -.message-content { padding: 16px; } -.message-content p { margin: 0 0 12px 0; } -.message-content p:last-child { margin-bottom: 0; } -.thinking { background: var(--thinking-bg); border: 1px solid var(--thinking-border); border-radius: 8px; padding: 12px; margin: 12px 0; font-size: 0.9rem; color: var(--thinking-text); } -.thinking-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; color: #f57c00; margin-bottom: 8px; } -.thinking p { margin: 8px 0; } -.assistant-text { margin: 8px 0; } -.tool-use { background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: 8px; padding: 12px; margin: 12px 0; } -.tool-header { font-weight: 600; color: var(--tool-border); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } -.tool-icon { font-size: 1.1rem; } -.tool-description { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; font-style: italic; } -.tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } -.tool-result.tool-error { background: var(--tool-error-bg); } -.file-tool { border-radius: 8px; padding: 12px; margin: 12px 0; } -.write-tool { background: linear-gradient(135deg, #e3f2fd 0%, #e8f5e9 100%); border: 1px solid #4caf50; } -.edit-tool { background: linear-gradient(135deg, #fff3e0 0%, #fce4ec 100%); border: 1px solid #ff9800; } -.file-tool-header { font-weight: 600; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; } -.write-header { color: #2e7d32; } -.edit-header { color: #e65100; } -.file-tool-icon { font-size: 1rem; } -.file-tool-path { font-family: monospace; background: rgba(0,0,0,0.08); padding: 2px 8px; border-radius: 4px; } -.file-tool-fullpath { font-family: monospace; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; word-break: break-all; } -.file-content { margin: 0; } -.edit-section { display: flex; margin: 4px 0; border-radius: 4px; overflow: hidden; } -.edit-label { padding: 8px 12px; font-weight: bold; font-family: monospace; display: flex; align-items: flex-start; } -.edit-old { background: #fce4ec; } -.edit-old .edit-label { color: #b71c1c; background: #f8bbd9; } -.edit-old .edit-content { color: #880e4f; } -.edit-new { background: #e8f5e9; } -.edit-new .edit-label { color: #1b5e20; background: #a5d6a7; } -.edit-new .edit-content { color: #1b5e20; } -.edit-content { margin: 0; flex: 1; background: transparent; font-size: 0.85rem; } -.edit-replace-all { font-size: 0.75rem; font-weight: normal; color: var(--text-muted); } -.write-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #e6f4ea); } -.edit-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff0e5); } -.todo-list { background: linear-gradient(135deg, #e8f5e9 0%, #f1f8e9 100%); border: 1px solid #81c784; border-radius: 8px; padding: 12px; margin: 12px 0; } -.todo-header { font-weight: 600; color: #2e7d32; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; font-size: 0.95rem; } -.todo-items { list-style: none; margin: 0; padding: 0; } -.todo-item { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; border-bottom: 1px solid rgba(0,0,0,0.06); font-size: 0.9rem; } -.todo-item:last-child { border-bottom: none; } -.todo-icon { flex-shrink: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-weight: bold; border-radius: 50%; } -.todo-completed .todo-icon { color: #2e7d32; background: rgba(46, 125, 50, 0.15); } -.todo-completed .todo-content { color: #558b2f; text-decoration: line-through; } -.todo-in-progress .todo-icon { color: #f57c00; background: rgba(245, 124, 0, 0.15); } -.todo-in-progress .todo-content { color: #e65100; font-weight: 500; } -.todo-pending .todo-icon { color: #757575; background: rgba(0,0,0,0.05); } -.todo-pending .todo-content { color: #616161; } -pre { background: var(--code-bg); color: var(--code-text); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; margin: 8px 0; white-space: pre-wrap; word-wrap: break-word; } -pre.json { color: #e0e0e0; } -code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } -pre code { background: none; padding: 0; } -.user-content { margin: 0; } -.truncatable { position: relative; } -.truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } -.truncatable.truncated::after { content: ''; position: absolute; bottom: 32px; left: 0; right: 0; height: 60px; background: linear-gradient(to bottom, transparent, var(--card-bg)); pointer-events: none; } -.message.user .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--user-bg)); } -.message.tool-reply .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } -.tool-use .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-bg)); } -.tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-result-bg)); } -.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); } -.expand-btn:hover { background: rgba(0,0,0,0.1); } -.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; } -.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; } -.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; } -.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); } -.pagination a:hover { background: var(--user-bg); } -.pagination .current { background: var(--user-border); color: white; } -.pagination .disabled { color: var(--text-muted); border: 1px solid #ddd; } -.pagination .index-link { background: var(--user-border); color: white; } -details.continuation { margin-bottom: 16px; } -details.continuation summary { cursor: pointer; padding: 12px 16px; background: var(--user-bg); border-left: 4px solid var(--user-border); border-radius: 12px; font-weight: 500; color: var(--text-muted); } -details.continuation summary:hover { background: rgba(25, 118, 210, 0.15); } -details.continuation[open] summary { border-radius: 12px 12px 0 0; margin-bottom: 0; } -.index-item { margin-bottom: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); background: var(--user-bg); border-left: 4px solid var(--user-border); } -.index-item a { display: block; text-decoration: none; color: inherit; } -.index-item a:hover { background: rgba(25, 118, 210, 0.1); } -.index-item-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; } -.index-item-number { font-weight: 600; color: var(--user-border); } -.index-item-content { padding: 16px; } -.index-item-stats { padding: 8px 16px 12px 32px; font-size: 0.85rem; color: var(--text-muted); border-top: 1px solid rgba(0,0,0,0.06); } -.index-item-commit { margin-top: 6px; padding: 4px 8px; background: #fff3e0; border-radius: 4px; font-size: 0.85rem; color: #e65100; } -.index-item-commit code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; margin-right: 6px; } -.commit-card { margin: 8px 0; padding: 10px 14px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 6px; } -.commit-card a { text-decoration: none; color: #5d4037; display: block; } -.commit-card a:hover { color: #e65100; } -.commit-card-hash { font-family: monospace; color: #e65100; font-weight: 600; margin-right: 8px; } -.index-commit { margin-bottom: 12px; padding: 10px 16px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 8px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } -.index-commit a { display: block; text-decoration: none; color: inherit; } -.index-commit a:hover { background: rgba(255, 152, 0, 0.1); margin: -10px -16px; padding: 10px 16px; border-radius: 8px; } -.index-commit-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; margin-bottom: 4px; } -.index-commit-hash { font-family: monospace; color: #e65100; font-weight: 600; } -.index-commit-msg { color: #5d4037; } -.index-item-long-text { margin-top: 8px; padding: 12px; background: var(--card-bg); border-radius: 8px; border-left: 3px solid var(--assistant-border); } -.index-item-long-text .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--card-bg)); } -.index-item-long-text-content { color: var(--text-color); } -@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } } -''' - -JS = ''' -document.querySelectorAll('time[data-timestamp]').forEach(function(el) { - const timestamp = el.getAttribute('data-timestamp'); - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); - const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - if (isToday) { el.textContent = timeStr; } - else { el.textContent = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + timeStr; } -}); -document.querySelectorAll('pre.json').forEach(function(el) { - let text = el.textContent; - text = text.replace(/"([^"]+)":/g, '"$1":'); - text = text.replace(/: "([^"]*)"/g, ': "$1"'); - text = text.replace(/: (\\d+)/g, ': $1'); - text = text.replace(/: (true|false|null)/g, ': $1'); - el.innerHTML = text; -}); -document.querySelectorAll('.truncatable').forEach(function(wrapper) { - const content = wrapper.querySelector('.truncatable-content'); - const btn = wrapper.querySelector('.expand-btn'); - if (content.scrollHeight > 250) { - wrapper.classList.add('truncated'); - btn.addEventListener('click', function() { - if (wrapper.classList.contains('truncated')) { wrapper.classList.remove('truncated'); wrapper.classList.add('expanded'); btn.textContent = 'Show less'; } - else { wrapper.classList.remove('expanded'); wrapper.classList.add('truncated'); btn.textContent = 'Show more'; } - }); - } -}); -''' - - -def generate_pagination_html(current_page, total_pages): - if total_pages <= 1: - return '' - parts = ['') - return '\n'.join(parts) - - -def generate_index_pagination_html(total_pages): - """Generate pagination for index page where Index is current (first page).""" - if total_pages < 1: - return '' - parts = ['') - return '\n'.join(parts) - - -def generate_html(json_path, output_dir, github_repo=None): - output_dir = Path(output_dir) - output_dir.mkdir(exist_ok=True) - - # Load JSON file - with open(json_path, "r") as f: - data = json.load(f) - - loglines = data.get("loglines", []) - - # Auto-detect GitHub repo if not provided - if github_repo is None: - github_repo = detect_github_repo(loglines) - if github_repo: - print(f"Auto-detected GitHub repo: {github_repo}") - else: - print("Warning: Could not auto-detect GitHub repo. Commit links will be disabled.") - - # Set module-level variable for render functions - global _github_repo - _github_repo = github_repo - - conversations = [] - current_conv = None - for entry in loglines: - log_type = entry.get("type") - timestamp = entry.get("timestamp", "") - is_compact_summary = entry.get("isCompactSummary", False) - message_data = entry.get("message", {}) - if not message_data: - continue - # Convert message dict to JSON string for compatibility with existing render functions - message_json = json.dumps(message_data) - is_user_prompt = False - user_text = None - if log_type == "user": - content = message_data.get("content", "") - if isinstance(content, str) and content.strip(): - is_user_prompt = True - user_text = content - if is_user_prompt: - if current_conv: - conversations.append(current_conv) - current_conv = { - "user_text": user_text, - "timestamp": timestamp, - "messages": [(log_type, message_json, timestamp)], - "is_continuation": bool(is_compact_summary), - } - elif current_conv: - current_conv["messages"].append((log_type, message_json, timestamp)) - if current_conv: - conversations.append(current_conv) - - total_convs = len(conversations) - total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE - - for page_num in range(1, total_pages + 1): - start_idx = (page_num - 1) * PROMPTS_PER_PAGE - end_idx = min(start_idx + PROMPTS_PER_PAGE, total_convs) - page_convs = conversations[start_idx:end_idx] - messages_html = [] - for conv in page_convs: - is_first = True - for log_type, message_json, timestamp in conv["messages"]: - msg_html = render_message(log_type, message_json, timestamp) - if msg_html: - # Wrap continuation summaries in collapsed details - if is_first and conv.get("is_continuation"): - msg_html = f'
    Session continuation summary{msg_html}
    ' - messages_html.append(msg_html) - is_first = False - pagination_html = generate_pagination_html(page_num, total_pages) - page_content = f''' - - - - - Claude Code transcript - page {page_num} - - - -
    -

    Claude Code transcript - page {page_num}/{total_pages}

    - {pagination_html} - {''.join(messages_html)} - {pagination_html} -
    - - -''' - (output_dir / f"page-{page_num:03d}.html").write_text(page_content) - print(f"Generated page-{page_num:03d}.html") - - # Calculate overall stats and collect all commits for timeline - total_tool_counts = {} - total_messages = 0 - all_commits = [] # (timestamp, hash, message, page_num, conv_index) - for i, conv in enumerate(conversations): - total_messages += len(conv["messages"]) - stats = analyze_conversation(conv["messages"]) - for tool, count in stats["tool_counts"].items(): - total_tool_counts[tool] = total_tool_counts.get(tool, 0) + count - page_num = (i // PROMPTS_PER_PAGE) + 1 - for commit_hash, commit_msg, commit_ts in stats["commits"]: - all_commits.append((commit_ts, commit_hash, commit_msg, page_num, i)) - total_tool_calls = sum(total_tool_counts.values()) - total_commits = len(all_commits) - - # Build timeline items: prompts and commits merged by timestamp - timeline_items = [] - - # Add prompts - prompt_num = 0 - for i, conv in enumerate(conversations): - if conv.get("is_continuation"): - continue - if conv["user_text"].startswith("Stop hook feedback:"): - continue - prompt_num += 1 - page_num = (i // PROMPTS_PER_PAGE) + 1 - msg_id = make_msg_id(conv["timestamp"]) - link = f"page-{page_num:03d}.html#{msg_id}" - rendered_content = render_markdown_text(conv["user_text"]) - - # Analyze conversation for stats (excluding commits from inline display now) - stats = analyze_conversation(conv["messages"]) - tool_stats_str = format_tool_stats(stats["tool_counts"]) - - stats_html = "" - if tool_stats_str or stats["long_texts"]: - long_texts_html = "" - for lt in stats["long_texts"]: - rendered_lt = render_markdown_text(lt) - long_texts_html += f'
    {rendered_lt}
    ' - - stats_line = f'{tool_stats_str}' if tool_stats_str else "" - stats_html = f'
    {stats_line}{long_texts_html}
    ' - - item_html = f'
    #{prompt_num}
    {rendered_content}
    {stats_html}
    ' - timeline_items.append((conv["timestamp"], "prompt", item_html)) - - # Add commits as separate timeline items - for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: - if _github_repo: - github_link = f"https://github.com/{_github_repo}/commit/{commit_hash}" - item_html = f'''
    {commit_hash[:7]}
    {html.escape(commit_msg)}
    ''' - else: - item_html = f'''
    {commit_hash[:7]}
    {html.escape(commit_msg)}
    ''' - timeline_items.append((commit_ts, "commit", item_html)) - - # Sort by timestamp - timeline_items.sort(key=lambda x: x[0]) - index_items = [item[2] for item in timeline_items] - - index_pagination = generate_index_pagination_html(total_pages) - index_content = f''' - - - - - Claude Code transcript - Index - - - -
    -

    Claude Code transcript

    - {index_pagination} -

    {prompt_num} prompts · {total_messages} messages · {total_tool_calls} tool calls · {total_commits} commits · {total_pages} pages

    - {''.join(index_items)} - {index_pagination} -
    - - -''' - (output_dir / "index.html").write_text(index_content) - print(f"Generated index.html ({total_convs} prompts, {total_pages} pages)") - - -def main(): - parser = argparse.ArgumentParser( - description="Convert Claude Code session JSON to mobile-friendly HTML pages." - ) - parser.add_argument( - "json_file", - help="Path to the Claude Code session JSON file" - ) - parser.add_argument( - "-o", "--output", - default=".", - help="Output directory (default: current directory)" - ) - parser.add_argument( - "--repo", - help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified." - ) - args = parser.parse_args() - generate_html(args.json_file, args.output, github_repo=args.repo) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py new file mode 100644 index 0000000..def9d6a --- /dev/null +++ b/src/claude_code_transcripts/__init__.py @@ -0,0 +1,3067 @@ +"""Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination.""" + +import contextvars +import json +import html +import os +import platform +import re +import shutil +import subprocess +import tempfile +import webbrowser +from datetime import datetime +from pathlib import Path + +import click +from click_default_group import DefaultGroup +import httpx +from jinja2 import Environment, PackageLoader +import markdown +from pygments import highlight +from pygments.lexers import get_lexer_for_filename, get_lexer_by_name, TextLexer +from pygments.formatters import HtmlFormatter +from pygments.util import ClassNotFound +import questionary + +# Set up Jinja2 environment +_jinja_env = Environment( + loader=PackageLoader("claude_code_transcripts", "templates"), + autoescape=True, +) + +# Load macros template and expose macros +_macros_template = _jinja_env.get_template("macros.html") +_macros = _macros_template.module + + +def get_template(name): + """Get a Jinja2 template by name.""" + return _jinja_env.get_template(name) + + +# Regex to match git commit output: [branch hash] message +COMMIT_PATTERN = re.compile(r"\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)") + +# Regex to detect GitHub repo from git push output (e.g., github.com/owner/repo/pull/new/branch) +GITHUB_REPO_PATTERN = re.compile( + r"github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/new/" +) + +PROMPTS_PER_PAGE = 5 +LONG_TEXT_THRESHOLD = ( + 300 # Characters - text blocks longer than this are shown in index +) + +# Tool type icons for display in tool headers +TOOL_ICONS = { + # File operations + "Read": "📖", + "Write": "📝", + "Edit": "✏️", + "NotebookEdit": "📓", + # Search/find operations + "Glob": "🔍", + "Grep": "🔎", + # Terminal operations + "Bash": "$", + # Web operations + "WebFetch": "🌐", + "WebSearch": "🔎", + # Task management + "TodoWrite": "☰", + "Task": "📋", + # Other tools + "Skill": "⚡", + "Agent": "🤖", +} + +# Default icon for tools not in the mapping +DEFAULT_TOOL_ICON = "⚙" + + +def get_tool_icon(tool_name): + """Get the appropriate icon for a tool name. + + Args: + tool_name: The name of the tool. + + Returns: + The icon string for the tool. + """ + return TOOL_ICONS.get(tool_name, DEFAULT_TOOL_ICON) + + +# Regex to strip ANSI escape sequences from terminal output +ANSI_ESCAPE_PATTERN = re.compile( + r""" + \x1b(?:\].*?(?:\x07|\x1b\\) # OSC sequences + |\[[0-?]*[ -/]*[@-~] # CSI sequences + |[@-Z\\-_]) # 7-bit C1 control codes + """, + re.VERBOSE | re.DOTALL, +) + + +def strip_ansi(text): + """Strip ANSI escape sequences from terminal output.""" + if not text: + return text + return ANSI_ESCAPE_PATTERN.sub("", text) + + +def is_content_block_array(text): + """Check if a string is a JSON array of content blocks. + + Args: + text: String to check. + + Returns: + True if the string is a valid JSON array of content blocks. + """ + if not text or not isinstance(text, str): + return False + text = text.strip() + if not (text.startswith("[") and text.endswith("]")): + return False + try: + parsed = json.loads(text) + if not isinstance(parsed, list): + return False + # Check if items look like content blocks + for item in parsed: + if isinstance(item, dict) and "type" in item: + return True + return False + except (json.JSONDecodeError, TypeError): + return False + + +def render_content_block_array(blocks): + """Render an array of content blocks. + + Args: + blocks: List of content block dicts. + + Returns: + HTML string with all blocks rendered. + """ + parts = [] + for block in blocks: + parts.append(render_content_block(block)) + return "".join(parts) if parts else None + + +def highlight_code(code, filename=None, language=None): + """Apply syntax highlighting to code using Pygments. + + Args: + code: The source code to highlight. + filename: Optional filename to detect language from extension. + language: Optional explicit language name. + + Returns: + HTML string with syntax highlighting, or escaped plain text if highlighting fails. + """ + if not code: + return "" + + try: + if language: + lexer = get_lexer_by_name(language) + elif filename: + lexer = get_lexer_for_filename(filename) + else: + lexer = TextLexer() + except ClassNotFound: + lexer = TextLexer() + + formatter = HtmlFormatter(nowrap=True, cssclass="highlight") + highlighted = highlight(code, lexer, formatter) + return highlighted + + +def calculate_message_metadata(message_data): + """Calculate metadata for a message. + + Args: + message_data: Parsed message JSON data. + + Returns: + Dict with char_count, token_estimate, and tool_counts. + """ + content = message_data.get("content", "") + + # Calculate character count from all text content + if isinstance(content, str): + char_count = len(content) + elif isinstance(content, list): + char_count = 0 + for block in content: + if isinstance(block, dict): + block_type = block.get("type", "") + if block_type == "text": + char_count += len(block.get("text", "")) + elif block_type == "thinking": + char_count += len(block.get("thinking", "")) + elif block_type == "tool_use": + # Count the input JSON as text + char_count += len(json.dumps(block.get("input", {}))) + elif block_type == "tool_result": + result_content = block.get("content", "") + if isinstance(result_content, str): + char_count += len(result_content) + elif isinstance(result_content, list): + for item in result_content: + if isinstance(item, dict) and item.get("type") == "text": + char_count += len(item.get("text", "")) + else: + char_count = len(str(content)) + + # Token estimate (approximately 4 characters per token) + token_estimate = char_count // 4 + + # Count tool calls + tool_counts = {} + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + tool_name = block.get("name", "Unknown") + tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1 + + return { + "char_count": char_count, + "token_estimate": token_estimate, + "tool_counts": tool_counts, + } + + +def extract_text_from_content(content): + """Extract plain text from message content. + + Handles both string content (older format) and array content (newer format). + + Args: + content: Either a string or a list of content blocks like + [{"type": "text", "text": "..."}, {"type": "image", ...}] + + Returns: + The extracted text as a string, or empty string if no text found. + """ + if isinstance(content, str): + return content.strip() + elif isinstance(content, list): + # Extract text from content blocks of type "text" + texts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text", "") + if text: + texts.append(text) + return " ".join(texts).strip() + return "" + + +# Thread-safe context variable for GitHub repo (set by generate_html) +# Using contextvars ensures thread-safety when processing multiple sessions concurrently +_github_repo_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "_github_repo", default=None +) + +# Backward compatibility: module-level variable that tests may still access +# This is deprecated - use get_github_repo() and set_github_repo() instead +_github_repo = None + + +def get_github_repo() -> str | None: + """Get the current GitHub repo from the thread-local context. + + This is the thread-safe way to access the GitHub repo setting. + Falls back to the module-level _github_repo for backward compatibility. + + Returns: + The GitHub repository in 'owner/repo' format, or None if not set. + """ + ctx_value = _github_repo_var.get() + if ctx_value is not None: + return ctx_value + # Fallback for backward compatibility + return _github_repo + + +def set_github_repo(repo: str | None) -> contextvars.Token[str | None]: + """Set the GitHub repo in the thread-local context. + + This is the thread-safe way to set the GitHub repo. Also updates + the module-level _github_repo for backward compatibility. + + Args: + repo: The GitHub repository in 'owner/repo' format, or None. + + Returns: + A token that can be used to reset the value later. + """ + global _github_repo + _github_repo = repo + return _github_repo_var.set(repo) + + +# API constants +API_BASE_URL = "https://api.anthropic.com/v1" +ANTHROPIC_VERSION = "2023-06-01" + + +def get_session_summary(filepath, max_length=200): + """Extract a human-readable summary from a session file. + + Supports both JSON and JSONL formats. + Returns a summary string or "(no summary)" if none found. + """ + filepath = Path(filepath) + try: + if filepath.suffix == ".jsonl": + return _get_jsonl_summary(filepath, max_length) + else: + # For JSON files, try to get first user message + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + loglines = data.get("loglines", []) + for entry in loglines: + if entry.get("type") == "user": + msg = entry.get("message", {}) + content = msg.get("content", "") + text = extract_text_from_content(content) + if text: + if len(text) > max_length: + return text[: max_length - 3] + "..." + return text + return "(no summary)" + except Exception: + return "(no summary)" + + +def _get_jsonl_summary(filepath, max_length=200): + """Extract summary from JSONL file.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + # First priority: summary type entries + if obj.get("type") == "summary" and obj.get("summary"): + summary = obj["summary"] + if len(summary) > max_length: + return summary[: max_length - 3] + "..." + return summary + except json.JSONDecodeError: + continue + + # Second pass: find first non-meta user message + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + if ( + obj.get("type") == "user" + and not obj.get("isMeta") + and obj.get("message", {}).get("content") + ): + content = obj["message"]["content"] + text = extract_text_from_content(content) + if text and not text.startswith("<"): + if len(text) > max_length: + return text[: max_length - 3] + "..." + return text + except json.JSONDecodeError: + continue + except Exception: + pass + + return "(no summary)" + + +def find_local_sessions(folder, limit=10): + """Find recent JSONL session files in the given folder. + + Returns a list of (Path, summary) tuples sorted by modification time. + Excludes agent files and warmup/empty sessions. + """ + folder = Path(folder) + if not folder.exists(): + return [] + + results = [] + for f in folder.glob("**/*.jsonl"): + if f.name.startswith("agent-"): + continue + summary = get_session_summary(f) + # Skip boring/empty sessions + if summary.lower() == "warmup" or summary == "(no summary)": + continue + results.append((f, summary)) + + # Sort by modification time, most recent first + results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True) + return results[:limit] + + +def get_project_display_name(folder_name): + """Convert encoded folder name to readable project name. + + Claude Code stores projects in folders like: + - -home-user-projects-myproject -> myproject + - -mnt-c-Users-name-Projects-app -> app + + For nested paths under common roots (home, projects, code, Users, etc.), + extracts the meaningful project portion. + """ + # Common path prefixes to strip + prefixes_to_strip = [ + "-home-", + "-mnt-c-Users-", + "-mnt-c-users-", + "-Users-", + ] + + name = folder_name + for prefix in prefixes_to_strip: + if name.lower().startswith(prefix.lower()): + name = name[len(prefix) :] + break + + # Split on dashes and find meaningful parts + parts = name.split("-") + + # Common intermediate directories to skip + skip_dirs = {"projects", "code", "repos", "src", "dev", "work", "documents"} + + # Find the first meaningful part (after skipping username and common dirs) + meaningful_parts = [] + found_project = False + + for i, part in enumerate(parts): + if not part: + continue + # Skip the first part if it looks like a username (before common dirs) + if i == 0 and not found_project: + # Check if next parts contain common dirs + remaining = [p.lower() for p in parts[i + 1 :]] + if any(d in remaining for d in skip_dirs): + continue + if part.lower() in skip_dirs: + found_project = True + continue + meaningful_parts.append(part) + found_project = True + + if meaningful_parts: + return "-".join(meaningful_parts) + + # Fallback: return last non-empty part or original + for part in reversed(parts): + if part: + return part + return folder_name + + +def find_all_sessions(folder, include_agents=False): + """Find all sessions in a Claude projects folder, grouped by project. + + Returns a list of project dicts, each containing: + - name: display name for the project + - path: Path to the project folder + - sessions: list of session dicts with path, summary, mtime, size + + Sessions are sorted by modification time (most recent first) within each project. + Projects are sorted by their most recent session. + """ + folder = Path(folder) + if not folder.exists(): + return [] + + projects = {} + + for session_file in folder.glob("**/*.jsonl"): + # Skip agent files unless requested + if not include_agents and session_file.name.startswith("agent-"): + continue + + # Get summary and skip boring sessions + summary = get_session_summary(session_file) + if summary.lower() == "warmup" or summary == "(no summary)": + continue + + # Get project folder + project_folder = session_file.parent + project_key = project_folder.name + + if project_key not in projects: + projects[project_key] = { + "name": get_project_display_name(project_key), + "path": project_folder, + "sessions": [], + } + + stat = session_file.stat() + projects[project_key]["sessions"].append( + { + "path": session_file, + "summary": summary, + "mtime": stat.st_mtime, + "size": stat.st_size, + } + ) + + # Sort sessions within each project by mtime (most recent first) + for project in projects.values(): + project["sessions"].sort(key=lambda s: s["mtime"], reverse=True) + + # Convert to list and sort projects by most recent session + result = list(projects.values()) + result.sort( + key=lambda p: p["sessions"][0]["mtime"] if p["sessions"] else 0, reverse=True + ) + + return result + + +def generate_batch_html( + source_folder, output_dir, include_agents=False, progress_callback=None +): + """Generate HTML archive for all sessions in a Claude projects folder. + + Creates: + - Master index.html listing all projects + - Per-project directories with index.html listing sessions + - Per-session directories with transcript pages + + Args: + source_folder: Path to the Claude projects folder + output_dir: Path for output archive + include_agents: Whether to include agent-* session files + progress_callback: Optional callback(project_name, session_name, current, total) + called after each session is processed + + Returns statistics dict with total_projects, total_sessions, failed_sessions, output_dir. + """ + source_folder = Path(source_folder) + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Find all sessions + projects = find_all_sessions(source_folder, include_agents=include_agents) + + # Calculate total for progress tracking + total_session_count = sum(len(p["sessions"]) for p in projects) + processed_count = 0 + successful_sessions = 0 + failed_sessions = [] + + # Process each project + for project in projects: + project_dir = output_dir / project["name"] + project_dir.mkdir(exist_ok=True) + + # Process each session + for session in project["sessions"]: + session_name = session["path"].stem + session_dir = project_dir / session_name + + # Generate transcript HTML with error handling + try: + generate_html(session["path"], session_dir) + successful_sessions += 1 + except Exception as e: + failed_sessions.append( + { + "project": project["name"], + "session": session_name, + "error": str(e), + } + ) + + processed_count += 1 + + # Call progress callback if provided + if progress_callback: + progress_callback( + project["name"], session_name, processed_count, total_session_count + ) + + # Generate project index + _generate_project_index(project, project_dir) + + # Generate master index + _generate_master_index(projects, output_dir) + + return { + "total_projects": len(projects), + "total_sessions": successful_sessions, + "failed_sessions": failed_sessions, + "output_dir": output_dir, + } + + +def _generate_project_index(project, output_dir): + """Generate index.html for a single project.""" + template = get_template("project_index.html") + + # Format sessions for template + sessions_data = [] + for session in project["sessions"]: + mod_time = datetime.fromtimestamp(session["mtime"]) + sessions_data.append( + { + "name": session["path"].stem, + "summary": session["summary"], + "date": mod_time.strftime("%Y-%m-%d %H:%M"), + "size_kb": session["size"] / 1024, + } + ) + + html_content = template.render( + project_name=project["name"], + sessions=sessions_data, + session_count=len(sessions_data), + css=CSS, + js=JS, + ) + + output_path = output_dir / "index.html" + output_path.write_text(html_content, encoding="utf-8") + + +def _generate_master_index(projects, output_dir): + """Generate master index.html listing all projects.""" + template = get_template("master_index.html") + + # Format projects for template + projects_data = [] + total_sessions = 0 + + for project in projects: + session_count = len(project["sessions"]) + total_sessions += session_count + + # Get most recent session date + if project["sessions"]: + most_recent = datetime.fromtimestamp(project["sessions"][0]["mtime"]) + recent_date = most_recent.strftime("%Y-%m-%d") + else: + recent_date = "N/A" + + projects_data.append( + { + "name": project["name"], + "session_count": session_count, + "recent_date": recent_date, + } + ) + + html_content = template.render( + projects=projects_data, + total_projects=len(projects), + total_sessions=total_sessions, + css=CSS, + js=JS, + ) + + output_path = output_dir / "index.html" + output_path.write_text(html_content, encoding="utf-8") + + +def parse_session_file(filepath): + """Parse a session file and return normalized data. + + Supports both JSON and JSONL formats. + Returns a dict with 'loglines' key containing the normalized entries. + """ + filepath = Path(filepath) + + if filepath.suffix == ".jsonl": + return _parse_jsonl_file(filepath) + else: + # Standard JSON format + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + + +def _parse_jsonl_file(filepath): + """Parse JSONL file and convert to standard format.""" + loglines = [] + + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + entry_type = obj.get("type") + + # Skip non-message entries + if entry_type not in ("user", "assistant"): + continue + + # Convert to standard format + entry = { + "type": entry_type, + "timestamp": obj.get("timestamp", ""), + "message": obj.get("message", {}), + } + + # Preserve isCompactSummary if present + if obj.get("isCompactSummary"): + entry["isCompactSummary"] = True + + loglines.append(entry) + except json.JSONDecodeError: + continue + + return {"loglines": loglines} + + +class CredentialsError(Exception): + """Raised when credentials cannot be obtained.""" + + pass + + +def get_access_token_from_keychain(): + """Get access token from macOS keychain. + + Returns the access token or None if not found. + Raises CredentialsError with helpful message on failure. + """ + if platform.system() != "Darwin": + return None + + try: + result = subprocess.run( + [ + "security", + "find-generic-password", + "-a", + os.environ.get("USER", ""), + "-s", + "Claude Code-credentials", + "-w", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + + # Parse the JSON to get the access token + creds = json.loads(result.stdout.strip()) + return creds.get("claudeAiOauth", {}).get("accessToken") + except (json.JSONDecodeError, subprocess.SubprocessError): + return None + + +def get_org_uuid_from_config(): + """Get organization UUID from ~/.claude.json. + + Returns the organization UUID or None if not found. + """ + config_path = Path.home() / ".claude.json" + if not config_path.exists(): + return None + + try: + with open(config_path) as f: + config = json.load(f) + return config.get("oauthAccount", {}).get("organizationUuid") + except (json.JSONDecodeError, IOError): + return None + + +def get_api_headers(token, org_uuid): + """Build API request headers.""" + return { + "Authorization": f"Bearer {token}", + "anthropic-version": ANTHROPIC_VERSION, + "Content-Type": "application/json", + "x-organization-uuid": org_uuid, + } + + +def fetch_sessions(token, org_uuid): + """Fetch list of sessions from the API. + + Returns the sessions data as a dict. + Raises httpx.HTTPError on network/API errors. + """ + headers = get_api_headers(token, org_uuid) + response = httpx.get(f"{API_BASE_URL}/sessions", headers=headers, timeout=30.0) + response.raise_for_status() + return response.json() + + +def fetch_session(token, org_uuid, session_id): + """Fetch a specific session from the API. + + Returns the session data as a dict. + Raises httpx.HTTPError on network/API errors. + """ + headers = get_api_headers(token, org_uuid) + response = httpx.get( + f"{API_BASE_URL}/session_ingress/session/{session_id}", + headers=headers, + timeout=60.0, + ) + response.raise_for_status() + return response.json() + + +def detect_github_repo(loglines): + """ + Detect GitHub repo from git push output in tool results. + + Looks for patterns like: + - github.com/owner/repo/pull/new/branch (from git push messages) + + Returns the first detected repo (owner/name) or None. + """ + for entry in loglines: + message = entry.get("message", {}) + content = message.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "tool_result": + result_content = block.get("content", "") + if isinstance(result_content, str): + match = GITHUB_REPO_PATTERN.search(result_content) + if match: + return match.group(1) + return None + + +def format_json(obj): + try: + if isinstance(obj, str): + obj = json.loads(obj) + formatted = json.dumps(obj, indent=2, ensure_ascii=False) + return f'
    {html.escape(formatted)}
    ' + except (json.JSONDecodeError, TypeError): + return f"
    {html.escape(str(obj))}
    " + + +def render_markdown_text(text): + if not text: + return "" + return markdown.markdown(text, extensions=["fenced_code", "tables"]) + + +def render_json_with_markdown(obj, indent=0): + """Render a JSON object/dict with string values as Markdown. + + Recursively traverses the object and renders string values as Markdown HTML. + Non-string values (numbers, booleans, null) are rendered as-is. + """ + indent_str = " " * indent + next_indent = " " * (indent + 1) + + if isinstance(obj, dict): + if not obj: + return "{}" + lines = ["{"] + items = list(obj.items()) + for i, (key, value) in enumerate(items): + comma = "," if i < len(items) - 1 else "" + rendered_value = render_json_with_markdown(value, indent + 1) + lines.append( + f'{next_indent}"{html.escape(str(key))}": {rendered_value}{comma}' + ) + lines.append(f"{indent_str}}}") + return "\n".join(lines) + elif isinstance(obj, list): + if not obj: + return "[]" + lines = ["["] + for i, item in enumerate(obj): + comma = "," if i < len(obj) - 1 else "" + rendered_item = render_json_with_markdown(item, indent + 1) + lines.append(f"{next_indent}{rendered_item}{comma}") + lines.append(f"{indent_str}]") + return "\n".join(lines) + elif isinstance(obj, str): + # Render string value as Markdown, wrap in a styled span + md_html = render_markdown_text(obj) + # Strip wrapping

    tags for inline display if it's a single paragraph + if ( + md_html.startswith("

    ") + and md_html.endswith("

    ") + and md_html.count("

    ") == 1 + ): + md_html = md_html[3:-4] + return f'{md_html}' + elif isinstance(obj, bool): + return ( + 'true' + if obj + else 'false' + ) + elif obj is None: + return 'null' + elif isinstance(obj, (int, float)): + return f'{obj}' + else: + return f'{html.escape(str(obj))}' + + +def is_json_like(text): + if not text or not isinstance(text, str): + return False + text = text.strip() + return (text.startswith("{") and text.endswith("}")) or ( + text.startswith("[") and text.endswith("]") + ) + + +def render_todo_write(tool_input, tool_id): + todos = tool_input.get("todos", []) + if not todos: + return "" + input_json_html = format_json(tool_input) + return _macros.todo_list(todos, input_json_html, tool_id) + + +def render_write_tool(tool_input, tool_id): + """Render Write tool calls with file path header and content preview.""" + file_path = tool_input.get("file_path", "Unknown file") + content = tool_input.get("content", "") + # Apply syntax highlighting based on file extension + highlighted_content = highlight_code(content, filename=file_path) + input_json_html = format_json(tool_input) + return _macros.write_tool(file_path, highlighted_content, input_json_html, tool_id) + + +def render_edit_tool(tool_input, tool_id): + """Render Edit tool calls with diff-like old/new display.""" + file_path = tool_input.get("file_path", "Unknown file") + old_string = tool_input.get("old_string", "") + new_string = tool_input.get("new_string", "") + replace_all = tool_input.get("replace_all", False) + # Apply syntax highlighting based on file extension + highlighted_old = highlight_code(old_string, filename=file_path) + highlighted_new = highlight_code(new_string, filename=file_path) + input_json_html = format_json(tool_input) + return _macros.edit_tool( + file_path, + highlighted_old, + highlighted_new, + replace_all, + input_json_html, + tool_id, + ) + + +def render_bash_tool(tool_input, tool_id): + """Render Bash tool calls with command as plain text and description as Markdown.""" + command = tool_input.get("command", "") + description = tool_input.get("description", "") + description_html = render_markdown_text(description) if description else "" + input_json_html = format_json(tool_input) + return _macros.bash_tool(command, description_html, input_json_html, tool_id) + + +def render_content_block(block): + if not isinstance(block, dict): + return f"

    {html.escape(str(block))}

    " + block_type = block.get("type", "") + if block_type == "image": + source = block.get("source", {}) + media_type = source.get("media_type", "image/png") + data = source.get("data", "") + return _macros.image_block(media_type, data) + elif block_type == "thinking": + content_html = render_markdown_text(block.get("thinking", "")) + return _macros.thinking(content_html) + elif block_type == "text": + content_html = render_markdown_text(block.get("text", "")) + return _macros.assistant_text(content_html) + elif block_type == "tool_use": + tool_name = block.get("name", "Unknown tool") + tool_input = block.get("input", {}) + tool_id = block.get("id", "") + if tool_name == "TodoWrite": + return render_todo_write(tool_input, tool_id) + if tool_name == "Write": + return render_write_tool(tool_input, tool_id) + if tool_name == "Edit": + return render_edit_tool(tool_input, tool_id) + if tool_name == "Bash": + return render_bash_tool(tool_input, tool_id) + description = tool_input.get("description", "") + description_html = render_markdown_text(description) if description else "" + display_input = {k: v for k, v in tool_input.items() if k != "description"} + input_markdown_html = render_json_with_markdown(display_input) + input_json_html = format_json(display_input) + tool_icon = get_tool_icon(tool_name) + return _macros.tool_use( + tool_name, + tool_icon, + description_html, + input_markdown_html, + input_json_html, + tool_id, + ) + elif block_type == "tool_result": + content = block.get("content", "") + is_error = block.get("is_error", False) + + # Strip ANSI escape sequences from string content for both views + if isinstance(content, str) and not is_content_block_array(content): + content = strip_ansi(content) + + # Generate JSON view (raw content as JSON) + content_json_html = format_json(content) + + # Generate Markdown view (rendered content) + # Check for git commits and render with styled cards + if isinstance(content, str): + # First, check if content is a JSON array of content blocks + if is_content_block_array(content): + try: + parsed_blocks = json.loads(content) + rendered = render_content_block_array(parsed_blocks) + if rendered: + content_markdown_html = rendered + else: + content_markdown_html = format_json(content) + except (json.JSONDecodeError, TypeError): + content_markdown_html = format_json(content) + else: + + commits_found = list(COMMIT_PATTERN.finditer(content)) + if commits_found: + # Build commit cards + remaining content + parts = [] + last_end = 0 + for match in commits_found: + # Add any content before this commit + before = content[last_end : match.start()].strip() + if before: + parts.append(f"
    {html.escape(before)}
    ") + + commit_hash = match.group(1) + commit_msg = match.group(2) + parts.append( + _macros.commit_card( + commit_hash, commit_msg, get_github_repo() + ) + ) + last_end = match.end() + + # Add any remaining content after last commit + after = content[last_end:].strip() + if after: + parts.append(f"
    {html.escape(after)}
    ") + + content_markdown_html = "".join(parts) + else: + # Check if content looks like JSON - if so, format as JSON + # Otherwise render as markdown + if is_json_like(content): + content_markdown_html = format_json(content) + else: + content_markdown_html = render_markdown_text(content) + elif isinstance(content, list) or is_json_like(content): + content_markdown_html = format_json(content) + else: + content_markdown_html = format_json(content) + return _macros.tool_result(content_markdown_html, content_json_html, is_error) + else: + return format_json(block) + + +def render_user_message_content(message_data): + content = message_data.get("content", "") + if isinstance(content, str): + if is_json_like(content): + content_html = format_json(content) + raw_content = content + else: + content_html = render_markdown_text(content) + raw_content = content + # Wrap in collapsible cell (open by default) + return _macros.cell("user", "Message", content_html, True, 0, raw_content) + elif isinstance(content, list): + blocks_html = "".join(render_content_block(block) for block in content) + raw_content = "\n\n".join( + block.get("text", "") if block.get("type") == "text" else str(block) + for block in content + ) + return _macros.cell("user", "Message", blocks_html, True, 0, raw_content) + return f"

    {html.escape(str(content))}

    " + + +def filter_tool_result_blocks(content, paired_tool_ids): + if not isinstance(content, list): + return content + filtered = [] + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_result" + and block.get("tool_use_id") in paired_tool_ids + ): + continue + filtered.append(block) + return filtered + + +def is_tool_result_content(content): + if not isinstance(content, list) or not content: + return False + return all( + isinstance(block, dict) and block.get("type") == "tool_result" + for block in content + ) + + +def render_user_message_content_with_tool_pairs(message_data, paired_tool_ids): + content = message_data.get("content", "") + if isinstance(content, str): + return render_user_message_content(message_data) + if isinstance(content, list): + filtered = filter_tool_result_blocks(content, paired_tool_ids) + if not filtered: + return "" + return "".join(render_content_block(block) for block in filtered) + return f"

    {html.escape(str(content))}

    " + + +def group_blocks_by_type(content_blocks): + """Group content blocks into thinking, text, and tool sections. + + Returns a dict with 'thinking', 'text', and 'tools' keys, + each containing a list of blocks of that type. + """ + thinking_blocks = [] + text_blocks = [] + tool_blocks = [] + + for block in content_blocks: + if not isinstance(block, dict): + continue + block_type = block.get("type", "") + if block_type == "thinking": + thinking_blocks.append(block) + elif block_type == "text": + text_blocks.append(block) + elif block_type in ("tool_use", "tool_result"): + tool_blocks.append(block) + + return {"thinking": thinking_blocks, "text": text_blocks, "tools": tool_blocks} + + +def render_assistant_message_with_tool_pairs( + message_data, tool_result_lookup, paired_tool_ids +): + """Render assistant message with tool_use/tool_result pairing and collapsible cells.""" + content = message_data.get("content", []) + if not isinstance(content, list): + return f"

    {html.escape(str(content))}

    " + + # Group blocks by type + groups = group_blocks_by_type(content) + cells = [] + + # Render thinking cell (closed by default) + if groups["thinking"]: + thinking_html = "".join( + render_content_block(block) for block in groups["thinking"] + ) + # Extract raw thinking text for copy functionality + raw_thinking = "\n\n".join( + block.get("thinking", "") for block in groups["thinking"] + ) + cells.append( + _macros.cell("thinking", "Thinking", thinking_html, False, 0, raw_thinking) + ) + + # Render response cell (open by default) + if groups["text"]: + text_html = "".join(render_content_block(block) for block in groups["text"]) + # Extract raw text for copy functionality + raw_text = "\n\n".join(block.get("text", "") for block in groups["text"]) + cells.append(_macros.cell("response", "Response", text_html, True, 0, raw_text)) + + # Render tools cell with pairing (closed by default) + if groups["tools"]: + tool_parts = [] + raw_tool_parts = [] + for block in groups["tools"]: + if not isinstance(block, dict): + tool_parts.append(f"

    {html.escape(str(block))}

    ") + raw_tool_parts.append(str(block)) + continue + if block.get("type") == "tool_use": + tool_id = block.get("id", "") + tool_name = block.get("name", "unknown") + tool_input = block.get("input", {}) + tool_result = tool_result_lookup.get(tool_id) + if tool_result: + paired_tool_ids.add(tool_id) + tool_use_html = render_content_block(block) + tool_result_html = render_content_block(tool_result) + tool_parts.append( + _macros.tool_pair(tool_use_html, tool_result_html) + ) + # Add raw content for tool use and result + raw_tool_parts.append( + f"Tool: {tool_name}\nInput: {json.dumps(tool_input, indent=2)}" + ) + result_content = tool_result.get("content", "") + if isinstance(result_content, list): + result_texts = [] + for item in result_content: + if isinstance(item, dict) and item.get("type") == "text": + result_texts.append(item.get("text", "")) + result_content = "\n".join(result_texts) + raw_tool_parts.append(f"Result:\n{result_content}") + continue + else: + raw_tool_parts.append( + f"Tool: {tool_name}\nInput: {json.dumps(tool_input, indent=2)}" + ) + elif block.get("type") == "tool_result": + result_content = block.get("content", "") + if isinstance(result_content, list): + result_texts = [] + for item in result_content: + if isinstance(item, dict) and item.get("type") == "text": + result_texts.append(item.get("text", "")) + result_content = "\n".join(result_texts) + raw_tool_parts.append(f"Result:\n{result_content}") + tool_parts.append(render_content_block(block)) + tools_html = "".join(tool_parts) + raw_tools = "\n\n".join(raw_tool_parts) + tool_count = len([b for b in groups["tools"] if b.get("type") == "tool_use"]) + cells.append( + _macros.cell( + "tools", "Tool Calls", tools_html, False, tool_count, raw_tools + ) + ) + + return "".join(cells) + + +def render_assistant_message(message_data): + """Render assistant message with collapsible cells for thinking/response/tools.""" + content = message_data.get("content", []) + if not isinstance(content, list): + return f"

    {html.escape(str(content))}

    " + + # Group blocks by type + groups = group_blocks_by_type(content) + cells = [] + + # Render thinking cell (closed by default) + if groups["thinking"]: + thinking_html = "".join( + render_content_block(block) for block in groups["thinking"] + ) + # Extract raw thinking text for copy functionality + raw_thinking = "\n\n".join( + block.get("thinking", "") for block in groups["thinking"] + ) + cells.append( + _macros.cell("thinking", "Thinking", thinking_html, False, 0, raw_thinking) + ) + + # Render response cell (open by default) + if groups["text"]: + text_html = "".join(render_content_block(block) for block in groups["text"]) + # Extract raw text for copy functionality + raw_text = "\n\n".join(block.get("text", "") for block in groups["text"]) + cells.append(_macros.cell("response", "Response", text_html, True, 0, raw_text)) + + # Render tools cell (closed by default) + if groups["tools"]: + tools_html = "".join(render_content_block(block) for block in groups["tools"]) + # Extract raw tool content for copy functionality + raw_tool_parts = [] + for block in groups["tools"]: + if not isinstance(block, dict): + raw_tool_parts.append(str(block)) + continue + if block.get("type") == "tool_use": + tool_name = block.get("name", "unknown") + tool_input = block.get("input", {}) + raw_tool_parts.append( + f"Tool: {tool_name}\nInput: {json.dumps(tool_input, indent=2)}" + ) + elif block.get("type") == "tool_result": + result_content = block.get("content", "") + if isinstance(result_content, list): + result_texts = [] + for item in result_content: + if isinstance(item, dict) and item.get("type") == "text": + result_texts.append(item.get("text", "")) + result_content = "\n".join(result_texts) + raw_tool_parts.append(f"Result:\n{result_content}") + raw_tools = "\n\n".join(raw_tool_parts) + tool_count = len([b for b in groups["tools"] if b.get("type") == "tool_use"]) + cells.append( + _macros.cell( + "tools", "Tool Calls", tools_html, False, tool_count, raw_tools + ) + ) + + return "".join(cells) + + +def make_msg_id(timestamp): + return f"msg-{timestamp.replace(':', '-').replace('.', '-')}" + + +def analyze_conversation(messages): + """Analyze messages in a conversation to extract stats and long texts.""" + tool_counts = {} # tool_name -> count + long_texts = [] + commits = [] # list of (hash, message, timestamp) + + for log_type, message_json, timestamp in messages: + if not message_json: + continue + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + continue + + content = message_data.get("content", []) + if not isinstance(content, list): + continue + + for block in content: + if not isinstance(block, dict): + continue + block_type = block.get("type", "") + + if block_type == "tool_use": + tool_name = block.get("name", "Unknown") + tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1 + elif block_type == "tool_result": + # Check for git commit output + result_content = block.get("content", "") + if isinstance(result_content, str): + for match in COMMIT_PATTERN.finditer(result_content): + commits.append((match.group(1), match.group(2), timestamp)) + elif block_type == "text": + text = block.get("text", "") + if len(text) >= LONG_TEXT_THRESHOLD: + long_texts.append(text) + + return { + "tool_counts": tool_counts, + "long_texts": long_texts, + "commits": commits, + } + + +def format_tool_stats(tool_counts): + """Format tool counts into a concise summary string.""" + if not tool_counts: + return "" + + # Abbreviate common tool names + abbrev = { + "Bash": "bash", + "Read": "read", + "Write": "write", + "Edit": "edit", + "Glob": "glob", + "Grep": "grep", + "Task": "task", + "TodoWrite": "todo", + "WebFetch": "fetch", + "WebSearch": "search", + } + + parts = [] + for name, count in sorted(tool_counts.items(), key=lambda x: -x[1]): + short_name = abbrev.get(name, name.lower()) + parts.append(f"{count} {short_name}") + + return " · ".join(parts) + + +def is_tool_result_message(message_data): + """Check if a message contains only tool_result blocks.""" + content = message_data.get("content", []) + if not isinstance(content, list): + return False + if not content: + return False + return all( + isinstance(block, dict) and block.get("type") == "tool_result" + for block in content + ) + + +def render_message(log_type, message_json, timestamp): + if not message_json: + return "" + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + return "" + if log_type == "user": + content_html = render_user_message_content(message_data) + # Check if this is a tool result message + if is_tool_result_message(message_data): + role_class, role_label = "tool-reply", "Tool reply" + else: + role_class, role_label = "user", "User" + elif log_type == "assistant": + content_html = render_assistant_message(message_data) + role_class, role_label = "assistant", "Assistant" + else: + return "" + if not content_html.strip(): + return "" + msg_id = make_msg_id(timestamp) + # Calculate and render metadata + metadata = calculate_message_metadata(message_data) + metadata_html = _macros.metadata( + metadata["char_count"], metadata["token_estimate"], metadata["tool_counts"] + ) + return _macros.message( + role_class, role_label, msg_id, timestamp, content_html, metadata_html + ) + + +def render_message_with_tool_pairs( + log_type, message_data, timestamp, tool_result_lookup, paired_tool_ids +): + if log_type == "user": + content = message_data.get("content", "") + filtered = filter_tool_result_blocks(content, paired_tool_ids) + content_html = render_user_message_content_with_tool_pairs( + message_data, paired_tool_ids + ) + if not content_html.strip(): + return "" + if is_tool_result_content(filtered): + role_class, role_label = "tool-reply", "Tool reply" + else: + role_class, role_label = "user", "User" + elif log_type == "assistant": + content_html = render_assistant_message_with_tool_pairs( + message_data, tool_result_lookup, paired_tool_ids + ) + role_class, role_label = "assistant", "Assistant" + else: + return "" + if not content_html.strip(): + return "" + msg_id = make_msg_id(timestamp) + # Calculate and render metadata + metadata = calculate_message_metadata(message_data) + metadata_html = _macros.metadata( + metadata["char_count"], metadata["token_estimate"], metadata["tool_counts"] + ) + return _macros.message( + role_class, role_label, msg_id, timestamp, content_html, metadata_html + ) + + +CSS = """ +:root { + /* Backgrounds - Craft.do inspired warm palette */ + --bg-primary: #faf9f7; /* Warm off-white */ + --bg-secondary: #f5f3f0; /* Cream */ + --bg-tertiary: #ebe8e4; /* Soft gray-cream */ + --bg-paper: #fffffe; /* Pure paper white */ + + /* Text Colors */ + --text-primary: #1a1a1a; /* Deep charcoal */ + --text-secondary: #4a4a4a; /* Warm dark gray */ + --text-muted: #7a7a7a; /* Medium gray */ + --text-subtle: #a0a0a0; /* Light gray */ + + /* Accent Colors */ + --accent-purple: #7c3aed; /* Primary purple */ + --accent-purple-light: #a78bfa; /* Light purple */ + --accent-purple-bg: rgba(124, 58, 237, 0.08); + --accent-blue: #0ea5e9; /* Sky blue */ + --accent-blue-light: #7dd3fc; + --accent-green: #10b981; /* Success green */ + --accent-green-bg: rgba(16, 185, 129, 0.08); + --accent-red: #ef4444; /* Error red */ + --accent-red-bg: rgba(239, 68, 68, 0.08); + --accent-orange: #f59e0b; /* Warning orange */ + + /* Surface & Cards */ + --card-bg: #fffffe; + --card-border: rgba(0, 0, 0, 0.06); + --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.03); + --card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.04); + + /* Borders & Dividers */ + --border-light: rgba(0, 0, 0, 0.06); + --border-medium: rgba(0, 0, 0, 0.1); + --border-radius-sm: 6px; + --border-radius-md: 10px; + --border-radius-lg: 14px; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* Sticky Header Heights */ + --sticky-level-0: 48px; /* Message header */ + --sticky-level-1: 44px; /* Cell header */ + --sticky-level-2: 40px; /* Subcell header */ + + /* Frosted Glass Effect */ + --glass-bg: rgba(255, 255, 254, 0.85); + --glass-blur: blur(12px); + --glass-border: rgba(255, 255, 255, 0.2); + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-medium: 0.25s ease; + + /* Typography */ + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + + /* Legacy variable mappings for backward compatibility */ + --bg-color: var(--bg-primary); + --user-bg: #e8f4fd; + --user-border: var(--accent-blue); + --assistant-bg: var(--bg-secondary); + --assistant-border: var(--border-medium); + --thinking-bg: #fef9e7; + --thinking-border: var(--accent-orange); + --thinking-text: var(--text-secondary); + --tool-bg: var(--accent-purple-bg); + --tool-border: var(--accent-purple); + --tool-result-bg: var(--accent-green-bg); + --tool-error-bg: var(--accent-red-bg); + --text-color: var(--text-primary); + --code-bg: #1e1e2e; + --code-text: #a6e3a1; +} +* { box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-primary); background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.03'/%3E%3C/svg%3E"); color: var(--text-primary); margin: 0; padding: var(--spacing-md); line-height: 1.6; } +.container { max-width: 800px; margin: 0 auto; } +h1 { font-size: 1.5rem; margin-bottom: 24px; padding-bottom: 8px; border-bottom: 2px solid var(--user-border); } +.header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; border-bottom: 2px solid var(--user-border); padding-bottom: 8px; margin-bottom: 24px; } +.header-row h1 { border-bottom: none; padding-bottom: 0; margin-bottom: 0; flex: 1; min-width: 200px; } +.message { margin-bottom: var(--spacing-md); border-radius: var(--border-radius-lg); box-shadow: var(--card-shadow); transition: box-shadow var(--transition-fast); } +.message.user { background: var(--user-bg); border-left: 4px solid var(--user-border); } +.message.assistant { background: var(--card-bg); border-left: 4px solid var(--assistant-border); } +.message.tool-reply { background: #fff8e1; border-left: 4px solid #ff9800; } +.tool-reply .role-label { color: #e65100; } +.tool-reply .tool-result { background: transparent; padding: 0; margin: 0; } +.tool-reply .tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } +.message-header { display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm) var(--spacing-md); background: var(--glass-bg); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); font-size: var(--font-size-sm); border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; position: sticky; top: 0; z-index: 30; } +.role-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } +.user .role-label { color: var(--user-border); } +time { color: var(--text-muted); font-size: 0.8rem; } +.timestamp-link { color: inherit; text-decoration: none; } +.timestamp-link:hover { text-decoration: underline; } +.message:target { animation: highlight 2s ease-out; } +@keyframes highlight { 0% { background-color: rgba(25, 118, 210, 0.2); } 100% { background-color: transparent; } } +.message-content { padding: var(--spacing-md); } +.message-content p { margin: 0 0 12px 0; } +.message-content p:last-child { margin-bottom: 0; } +.thinking { background: var(--thinking-bg); border: 1px solid var(--thinking-border); border-radius: var(--border-radius-md); padding: var(--spacing-md); margin: var(--spacing-md) 0; font-size: var(--font-size-sm); color: var(--thinking-text); } +.thinking-label { font-size: var(--font-size-xs); font-weight: 600; text-transform: uppercase; color: var(--accent-orange); margin-bottom: var(--spacing-sm); } +.thinking p { margin: 8px 0; } +.assistant-text { margin: 8px 0; } +.cell { margin: var(--spacing-sm) 0; border-radius: var(--border-radius-md); overflow: visible; } +.cell summary { cursor: pointer; padding: var(--spacing-sm) var(--spacing-md); display: flex; align-items: center; font-weight: 600; font-size: var(--font-size-sm); list-style: none; position: sticky; top: var(--sticky-level-0); z-index: 20; background: inherit; backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); gap: var(--spacing-sm); } +.cell summary .cell-label { flex: 1; } +.cell summary::-webkit-details-marker { display: none; } +.cell summary::before { content: '▶'; font-size: var(--font-size-xs); margin-right: var(--spacing-sm); transition: transform var(--transition-fast); } +.cell[open] summary::before { transform: rotate(90deg); } +.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: var(--accent-orange); border-radius: var(--border-radius-md); transition: background var(--transition-fast), border-color var(--transition-fast); } +.thinking-cell summary:hover { background: rgba(254, 249, 231, 0.9); border-color: var(--accent-orange); } +.thinking-cell[open] summary { border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; } +.response-cell summary { background: var(--border-light); border: 1px solid var(--assistant-border); color: var(--text-primary); border-radius: var(--border-radius-md); transition: background var(--transition-fast), border-color var(--transition-fast); } +.response-cell summary:hover { background: var(--bg-tertiary); border-color: var(--border-medium); } +.response-cell[open] summary { border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; } +.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--accent-purple); border-radius: var(--border-radius-md); transition: background var(--transition-fast), border-color var(--transition-fast); } +.tools-cell summary:hover { background: rgba(124, 58, 237, 0.12); border-color: var(--accent-purple); } +.tools-cell[open] summary { border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; } +.user-cell summary { background: var(--user-bg); border: 1px solid var(--user-border); color: var(--accent-blue); border-radius: var(--border-radius-md); transition: var(--transition-fast); } +.user-cell summary:hover { background: rgba(227, 242, 253, 0.9); border-color: var(--accent-blue); } +.user-cell[open] summary { border-radius: var(--border-radius-md) var(--border-radius-md) 0 0; } +.user-cell .cell-content { background: var(--user-bg); border-color: var(--user-border); } +.cell-content { padding: var(--spacing-md); border: 1px solid var(--border-medium); border-top: none; border-radius: 0 0 var(--border-radius-md) var(--border-radius-md); background: var(--card-bg); } +.thinking-cell .cell-content { background: var(--thinking-bg); border-color: var(--thinking-border); } +.tools-cell .cell-content { background: var(--accent-purple-bg); border-color: var(--tool-border); } +.cell-copy-btn { padding: var(--spacing-xs) var(--spacing-sm); background: var(--glass-bg); border: 1px solid var(--border-light); border-radius: var(--border-radius-sm); cursor: pointer; font-size: var(--font-size-xs); color: var(--text-muted); transition: all var(--transition-fast); margin-left: auto; } +.cell-copy-btn:hover { background: var(--bg-paper); color: var(--text-primary); border-color: var(--border-medium); } +.cell-copy-btn:focus { outline: 2px solid var(--accent-blue); outline-offset: 2px; } +.cell-copy-btn.copied { background: var(--accent-green-bg); color: var(--accent-green); border-color: var(--accent-green); } +.tool-use { background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: var(--border-radius-md); padding: var(--spacing-md); margin: var(--spacing-md) 0; } +.tool-header { font-weight: 600; color: var(--accent-purple); margin-bottom: var(--spacing-sm); display: flex; align-items: center; gap: var(--spacing-sm); position: sticky; top: calc(var(--sticky-level-0) + var(--sticky-level-1)); z-index: 10; background: var(--glass-bg); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); padding: var(--spacing-xs) 0; flex-wrap: wrap; } +.tool-icon { font-size: var(--font-size-lg); min-width: 1.5em; text-align: center; } +.tool-description { font-size: var(--font-size-sm); color: var(--text-muted); margin-bottom: var(--spacing-sm); font-style: italic; } +.tool-description p { margin: 0; } +.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: var(--font-size-sm); line-height: 1.5; } +/* Tab-style view toggle (shadcn inspired) */ +.view-toggle { display: inline-flex; background: var(--bg-tertiary); border-radius: var(--border-radius-sm); padding: 2px; gap: 2px; margin-left: auto; } +.view-toggle-tab { padding: var(--spacing-xs) var(--spacing-sm); font-size: var(--font-size-xs); font-weight: 500; color: var(--text-muted); background: transparent; border: none; border-radius: 4px; cursor: pointer; transition: var(--transition-fast); white-space: nowrap; } +.view-toggle-tab:hover { color: var(--text-secondary); background: rgba(0, 0, 0, 0.04); } +.view-toggle-tab.active { color: var(--text-primary); background: var(--bg-paper); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); } +.view-json { display: none; } +.view-markdown { display: block; } +.show-json .view-json { display: block; } +.show-json .view-markdown { display: none; } +.tool-result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-sm); position: sticky; top: calc(var(--sticky-level-0) + var(--sticky-level-1)); z-index: 10; background: var(--glass-bg); backdrop-filter: var(--glass-blur); -webkit-backdrop-filter: var(--glass-blur); padding: var(--spacing-xs) 0; } +.tool-result-label { font-weight: 600; font-size: var(--font-size-sm); color: var(--accent-green); display: flex; align-items: center; gap: var(--spacing-sm); } +.tool-result.tool-error .tool-result-label { color: var(--accent-red); } +.result-icon { font-size: var(--font-size-base); } +.tool-call-label { font-weight: 600; font-size: var(--font-size-xs); color: var(--accent-purple); background: var(--accent-purple-bg); padding: 2px var(--spacing-sm); border-radius: var(--border-radius-sm); margin-right: var(--spacing-sm); display: inline-flex; align-items: center; gap: var(--spacing-xs); } +.call-icon { font-size: var(--font-size-sm); } +.json-key { color: var(--accent-purple); font-weight: 600; } +.json-string-value { color: var(--accent-green); } +.json-string-value p { display: inline; margin: 0; } +.json-string-value code { background: var(--border-light); padding: 1px var(--spacing-xs); border-radius: 3px; } +.json-string-value strong { font-weight: 600; } +.json-string-value em { font-style: italic; } +.json-string-value a { color: var(--accent-blue); text-decoration: underline; } +.json-number { color: var(--accent-red); font-weight: 500; } +.json-bool { color: var(--accent-blue); font-weight: 600; } +.json-null { color: var(--text-muted); font-style: italic; } +.tool-result { background: var(--tool-result-bg); border-radius: var(--border-radius-md); padding: var(--spacing-md); margin: var(--spacing-md) 0; } +.tool-result.tool-error { background: var(--tool-error-bg); } +.tool-pair { border: 1px solid var(--tool-border); border-radius: var(--border-radius-md); padding: var(--spacing-sm); margin: var(--spacing-md) 0; background: var(--accent-purple-bg); } +.tool-pair .tool-use, .tool-pair .tool-result { margin: var(--spacing-sm) 0; } +.file-tool { border-radius: var(--border-radius-md); padding: var(--spacing-md); margin: var(--spacing-md) 0; } +.write-tool { background: linear-gradient(135deg, rgba(14, 165, 233, 0.08) 0%, rgba(16, 185, 129, 0.08) 100%); border: 1px solid var(--accent-green); } +.edit-tool { background: linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(239, 68, 68, 0.05) 100%); border: 1px solid var(--accent-orange); } +.file-tool-header { font-weight: 600; margin-bottom: var(--spacing-xs); display: flex; align-items: center; gap: var(--spacing-sm); font-size: var(--font-size-sm); flex-wrap: wrap; } +.write-header { color: var(--accent-green); } +.edit-header { color: var(--accent-orange); } +.file-tool-icon { font-size: var(--font-size-base); } +.file-tool-path { font-family: monospace; background: var(--border-light); padding: 2px var(--spacing-sm); border-radius: var(--border-radius-sm); } +.file-tool-fullpath { font-family: monospace; font-size: var(--font-size-xs); color: var(--text-muted); margin-bottom: var(--spacing-sm); word-break: break-all; } +.file-content { margin: 0; } +.edit-section { display: flex; margin: var(--spacing-xs) 0; border-radius: var(--border-radius-sm); overflow: hidden; } +.edit-label { padding: var(--spacing-sm) var(--spacing-md); font-weight: bold; font-family: monospace; display: flex; align-items: flex-start; } +.edit-old { background: var(--accent-red-bg); } +.edit-old .edit-label { color: var(--accent-red); background: rgba(239, 68, 68, 0.15); } +.edit-old .edit-content { color: var(--accent-red); } +.edit-new { background: var(--accent-green-bg); } +.edit-new .edit-label { color: var(--accent-green); background: rgba(16, 185, 129, 0.15); } +.edit-new .edit-content { color: var(--accent-green); } +.edit-content { margin: 0; flex: 1; background: transparent; font-size: var(--font-size-sm); } +.edit-replace-all { font-size: var(--font-size-xs); font-weight: normal; color: var(--text-muted); } +.write-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, rgba(16, 185, 129, 0.08)); } +.edit-tool .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, rgba(245, 158, 11, 0.08)); } +.todo-list { background: linear-gradient(135deg, var(--accent-green-bg) 0%, rgba(16, 185, 129, 0.04) 100%); border: 1px solid var(--accent-green); border-radius: var(--border-radius-md); padding: var(--spacing-md); margin: var(--spacing-md) 0; } +.todo-header { font-weight: 600; color: var(--accent-green); margin-bottom: var(--spacing-sm); display: flex; align-items: center; gap: var(--spacing-sm); font-size: var(--font-size-sm); flex-wrap: wrap; } +.todo-items { list-style: none; margin: 0; padding: 0; } +.todo-item { display: flex; align-items: flex-start; gap: var(--spacing-sm); padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border-light); font-size: var(--font-size-sm); } +.todo-item:last-child { border-bottom: none; } +.todo-icon { flex-shrink: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-weight: bold; border-radius: 50%; } +.todo-completed .todo-icon { color: var(--accent-green); background: var(--accent-green-bg); } +.todo-completed .todo-content { color: var(--accent-green); text-decoration: line-through; } +.todo-in-progress .todo-icon { color: var(--accent-orange); background: rgba(245, 158, 11, 0.15); } +.todo-in-progress .todo-content { color: var(--accent-orange); font-weight: 500; } +.todo-pending .todo-icon { color: var(--text-muted); background: var(--border-light); } +.todo-pending .todo-content { color: var(--text-secondary); } +pre { background: var(--code-bg); color: var(--code-text); padding: var(--spacing-md); border-radius: var(--border-radius-sm); overflow-x: auto; font-size: var(--font-size-sm); line-height: 1.5; margin: var(--spacing-sm) 0; white-space: pre-wrap; word-wrap: break-word; } +pre.json { color: #e0e0e0; } +pre.highlight { color: #e0e0e0; } +code { background: var(--border-light); padding: 2px var(--spacing-sm); border-radius: var(--border-radius-sm); font-size: 0.9em; } +pre code { background: none; padding: 0; } +.highlight .hll { background-color: #49483e } +.highlight .c { color: #8a9a5b; font-style: italic; } /* Comment - softer green-gray, italic */ +.highlight .err { color: #ff6b6b } /* Error - softer red */ +.highlight .k { color: #ff79c6; font-weight: 600; } /* Keyword - pink, bold */ +.highlight .l { color: #bd93f9 } /* Literal - purple */ +.highlight .n { color: #f8f8f2 } /* Name - bright white */ +.highlight .o { color: #ff79c6 } /* Operator - pink */ +.highlight .p { color: #f8f8f2 } /* Punctuation - bright white */ +.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #8a9a5b; font-style: italic; } /* Comments - softer green-gray, italic */ +.highlight .gd { color: #ff6b6b; background: rgba(255,107,107,0.15); } /* Generic.Deleted - red with bg */ +.highlight .gi { color: #50fa7b; background: rgba(80,250,123,0.15); } /* Generic.Inserted - green with bg */ +.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #8be9fd; font-weight: 600; } /* Keywords - cyan, bold */ +.highlight .ld { color: #f1fa8c } /* Literal.Date - yellow */ +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #bd93f9 } /* Numbers - purple */ +.highlight .s, .highlight .sa, .highlight .sb, .highlight .sc, .highlight .dl, .highlight .sd, .highlight .s2, .highlight .se, .highlight .sh, .highlight .si, .highlight .sx, .highlight .sr, .highlight .s1, .highlight .ss { color: #f1fa8c } /* Strings - yellow */ +.highlight .na { color: #50fa7b } /* Name.Attribute - green */ +.highlight .nb { color: #8be9fd } /* Name.Builtin - cyan */ +.highlight .nc { color: #50fa7b; font-weight: 600; } /* Name.Class - green, bold */ +.highlight .no { color: #8be9fd } /* Name.Constant - cyan */ +.highlight .nd { color: #ffb86c } /* Name.Decorator - orange */ +.highlight .ne { color: #ff79c6 } /* Name.Exception - pink */ +.highlight .nf { color: #50fa7b } /* Name.Function - green */ +.highlight .nl { color: #f8f8f2 } /* Name.Label - white */ +.highlight .nn { color: #f8f8f2 } /* Name.Namespace - white */ +.highlight .nt { color: #ff79c6 } /* Name.Tag - pink */ +.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #f8f8f2 } /* Variables - white */ +.highlight .ow { color: #ff79c6; font-weight: 600; } /* Operator.Word - pink, bold */ +.highlight .w { color: #f8f8f2 } /* Text.Whitespace */ +.user-content { margin: 0; overflow-wrap: break-word; word-break: break-word; } +.truncatable { position: relative; } +.truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } +.truncatable.truncated::after { content: ''; position: absolute; bottom: 32px; left: 0; right: 0; height: 60px; background: linear-gradient(to bottom, transparent, var(--card-bg)); pointer-events: none; } +.message.user .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--user-bg)); } +.message.tool-reply .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, #fff8e1); } +.tool-use .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-bg)); } +.tool-result .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--tool-result-bg)); } +.expand-btn { display: none; width: 100%; padding: var(--spacing-sm) var(--spacing-md); margin-top: var(--spacing-xs); background: var(--border-light); border: 1px solid var(--border-medium); border-radius: var(--border-radius-sm); cursor: pointer; font-size: var(--font-size-sm); color: var(--text-muted); transition: background var(--transition-fast); } +.expand-btn:hover { background: var(--bg-tertiary); } +.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; } +.copy-btn { position: absolute; top: var(--spacing-sm); right: var(--spacing-sm); padding: var(--spacing-xs) var(--spacing-sm); background: var(--glass-bg); border: 1px solid var(--border-light); border-radius: var(--border-radius-sm); cursor: pointer; font-size: var(--font-size-xs); color: var(--text-muted); opacity: 0; transition: opacity var(--transition-fast); z-index: 10; } +.copy-btn:hover { background: var(--bg-paper); color: var(--text-primary); } +.copy-btn.copied { background: var(--accent-green-bg); color: var(--accent-green); } +pre:hover .copy-btn, .tool-result:hover .copy-btn, .truncatable:hover .copy-btn { opacity: 1; } +.code-container { position: relative; } +.pagination { display: flex; justify-content: center; gap: var(--spacing-sm); margin: var(--spacing-lg) 0; flex-wrap: wrap; } +.pagination a, .pagination span { padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--border-radius-sm); text-decoration: none; font-size: var(--font-size-sm); } +.pagination a { background: var(--card-bg); color: var(--accent-blue); border: 1px solid var(--accent-blue); transition: background var(--transition-fast); } +.pagination a:hover { background: rgba(14, 165, 233, 0.1); } +.pagination .current { background: var(--accent-blue); color: white; } +.pagination .disabled { color: var(--text-muted); border: 1px solid var(--border-light); } +.pagination .index-link { background: var(--accent-blue); color: white; } +details.continuation { margin-bottom: var(--spacing-md); } +details.continuation summary { cursor: pointer; padding: var(--spacing-md); background: var(--user-bg); border-left: 4px solid var(--accent-blue); border-radius: var(--border-radius-lg); font-weight: 500; color: var(--text-muted); transition: background var(--transition-fast); } +details.continuation summary:hover { background: rgba(14, 165, 233, 0.15); } +details.continuation[open] summary { border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; margin-bottom: 0; } +.index-item { margin-bottom: var(--spacing-md); border-radius: var(--border-radius-lg); overflow: hidden; box-shadow: var(--card-shadow); background: var(--user-bg); border-left: 4px solid var(--accent-blue); transition: box-shadow var(--transition-fast); } +.index-item:hover { box-shadow: var(--card-shadow-hover); } +.index-item a { display: block; text-decoration: none; color: inherit; } +.index-item a:hover { background: rgba(14, 165, 233, 0.08); } +.index-item-header { display: flex; justify-content: space-between; align-items: center; padding: var(--spacing-sm) var(--spacing-md); background: var(--border-light); font-size: var(--font-size-sm); } +.index-item-number { font-weight: 600; color: var(--accent-blue); } +.index-item-content { padding: var(--spacing-md); } +.index-item-stats { padding: var(--spacing-sm) var(--spacing-md) var(--spacing-md) var(--spacing-xl); font-size: var(--font-size-sm); color: var(--text-muted); border-top: 1px solid var(--border-light); } +.index-item-commit { margin-top: var(--spacing-sm); padding: var(--spacing-xs) var(--spacing-sm); background: rgba(245, 158, 11, 0.1); border-radius: var(--border-radius-sm); font-size: var(--font-size-sm); color: var(--accent-orange); } +.index-item-commit code { background: var(--border-light); padding: 1px var(--spacing-xs); border-radius: 3px; font-size: var(--font-size-xs); margin-right: var(--spacing-sm); } +.commit-card { margin: var(--spacing-sm) 0; padding: var(--spacing-sm) var(--spacing-md); background: rgba(245, 158, 11, 0.1); border-left: 4px solid var(--accent-orange); border-radius: var(--border-radius-sm); } +.commit-card a { text-decoration: none; color: var(--text-secondary); display: block; } +.commit-card a:hover { color: var(--accent-orange); } +.commit-card-hash { font-family: monospace; color: var(--accent-orange); font-weight: 600; margin-right: var(--spacing-sm); } +.index-commit { margin-bottom: var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md); background: rgba(245, 158, 11, 0.1); border-left: 4px solid var(--accent-orange); border-radius: var(--border-radius-md); box-shadow: var(--card-shadow); } +.index-commit a { display: block; text-decoration: none; color: inherit; } +.index-commit a:hover { background: rgba(245, 158, 11, 0.1); margin: calc(-1 * var(--spacing-sm)) calc(-1 * var(--spacing-md)); padding: var(--spacing-sm) var(--spacing-md); border-radius: var(--border-radius-md); } +.index-commit-header { display: flex; justify-content: space-between; align-items: center; font-size: var(--font-size-sm); margin-bottom: var(--spacing-xs); } +.index-commit-hash { font-family: monospace; color: var(--accent-orange); font-weight: 600; } +.index-commit-msg { color: var(--text-secondary); } +.index-item-long-text { margin-top: var(--spacing-sm); padding: var(--spacing-md); background: var(--card-bg); border-radius: var(--border-radius-md); border-left: 3px solid var(--assistant-border); } +.index-item-long-text .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--card-bg)); } +.index-item-long-text-content { color: var(--text-primary); } +#search-box { display: none; align-items: center; gap: var(--spacing-sm); } +#search-box input { padding: var(--spacing-sm) var(--spacing-md); border: 1px solid var(--border-medium); border-radius: var(--border-radius-sm); font-size: var(--font-size-base); width: 180px; transition: border-color var(--transition-fast); } +#search-box input:focus { border-color: var(--accent-blue); outline: none; } +#search-box button, #modal-search-btn, #modal-close-btn { background: var(--accent-blue); color: white; border: none; border-radius: var(--border-radius-sm); padding: var(--spacing-sm) var(--spacing-sm); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background var(--transition-fast); } +#search-box button:hover, #modal-search-btn:hover { background: #0284c7; } +#modal-close-btn { background: var(--text-muted); margin-left: var(--spacing-sm); } +#modal-close-btn:hover { background: var(--text-secondary); } +#search-modal[open] { border: none; border-radius: var(--border-radius-lg); box-shadow: 0 4px 24px rgba(0,0,0,0.15); padding: 0; width: 90vw; max-width: 900px; height: 80vh; max-height: 80vh; display: flex; flex-direction: column; } +#search-modal::backdrop { background: rgba(0,0,0,0.4); } +.search-modal-header { display: flex; align-items: center; gap: var(--spacing-sm); padding: var(--spacing-md); border-bottom: 1px solid var(--border-medium); background: var(--bg-primary); border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0; } +.search-modal-header input { flex: 1; padding: var(--spacing-sm) var(--spacing-md); border: 1px solid var(--border-medium); border-radius: var(--border-radius-sm); font-size: var(--font-size-base); } +#search-status { padding: var(--spacing-sm) var(--spacing-md); font-size: var(--font-size-sm); color: var(--text-muted); border-bottom: 1px solid var(--border-light); } +#search-results { flex: 1; overflow-y: auto; padding: var(--spacing-md); } +.search-result { margin-bottom: var(--spacing-md); border-radius: var(--border-radius-md); overflow: hidden; box-shadow: var(--card-shadow); } +.search-result a { display: block; text-decoration: none; color: inherit; } +.search-result a:hover { background: rgba(14, 165, 233, 0.05); } +.search-result-page { padding: var(--spacing-sm) var(--spacing-md); background: var(--border-light); font-size: var(--font-size-xs); color: var(--text-muted); border-bottom: 1px solid var(--border-light); } +.search-result-content { padding: var(--spacing-md); } +.search-result mark { background: rgba(245, 158, 11, 0.3); padding: 1px 2px; border-radius: 2px; } +/* Metadata subsection */ +.message-metadata { margin: 0; border-radius: var(--border-radius-sm); font-size: var(--font-size-xs); } +.message-metadata summary { cursor: pointer; padding: var(--spacing-xs) var(--spacing-sm); color: var(--text-muted); list-style: none; display: flex; align-items: center; gap: var(--spacing-xs); } +.message-metadata summary::-webkit-details-marker { display: none; } +.message-metadata summary::before { content: 'i'; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; font-size: 10px; font-weight: 600; font-style: italic; font-family: Georgia, serif; background: var(--border-light); border-radius: 50%; color: var(--text-muted); } +.message-metadata[open] summary { border-bottom: 1px solid var(--border-light); } +.metadata-content { padding: var(--spacing-sm); background: var(--bg-secondary); border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm); display: flex; flex-wrap: wrap; gap: var(--spacing-sm) var(--spacing-md); } +.metadata-item { display: flex; align-items: center; gap: var(--spacing-xs); } +.metadata-label { color: var(--text-muted); font-weight: 500; } +.metadata-value { color: var(--text-secondary); font-family: monospace; } +@media (max-width: 600px) { body { padding: var(--spacing-sm); } .message, .index-item { border-radius: var(--border-radius-md); } .message-content, .index-item-content { padding: var(--spacing-md); } pre { font-size: var(--font-size-xs); padding: var(--spacing-sm); } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } +""" + +JS = """ +// Clipboard helper with fallback for older browsers +function copyToClipboard(text) { + // Modern browsers: use Clipboard API + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + // Fallback: use execCommand('copy') + return new Promise(function(resolve, reject) { + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + textarea.setAttribute('readonly', ''); + document.body.appendChild(textarea); + textarea.select(); + try { + var success = document.execCommand('copy'); + document.body.removeChild(textarea); + if (success) { resolve(); } + else { reject(new Error('execCommand copy failed')); } + } catch (err) { + document.body.removeChild(textarea); + reject(err); + } + }); +} +document.querySelectorAll('time[data-timestamp]').forEach(function(el) { + const timestamp = el.getAttribute('data-timestamp'); + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (isToday) { el.textContent = timeStr; } + else { el.textContent = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + timeStr; } +}); +document.querySelectorAll('pre.json').forEach(function(el) { + let text = el.textContent; + text = text.replace(/"([^"]+)":/g, '"$1":'); + text = text.replace(/: "([^"]*)"/g, ': "$1"'); + text = text.replace(/: (\\d+)/g, ': $1'); + text = text.replace(/: (true|false|null)/g, ': $1'); + el.innerHTML = text; +}); +document.querySelectorAll('.truncatable').forEach(function(wrapper) { + const content = wrapper.querySelector('.truncatable-content'); + const btn = wrapper.querySelector('.expand-btn'); + if (content.scrollHeight > 250) { + wrapper.classList.add('truncated'); + btn.addEventListener('click', function() { + if (wrapper.classList.contains('truncated')) { wrapper.classList.remove('truncated'); wrapper.classList.add('expanded'); btn.textContent = 'Show less'; } + else { wrapper.classList.remove('expanded'); wrapper.classList.add('truncated'); btn.textContent = 'Show more'; } + }); + } +}); +// Add copy buttons to pre elements and tool results +document.querySelectorAll('pre, .tool-result .truncatable-content, .bash-command').forEach(function(el) { + // Skip if already has a copy button + if (el.querySelector('.copy-btn')) return; + // Skip if inside a cell (cell header has its own copy button) + if (el.closest('.cell-content')) return; + // Make container relative if needed + if (getComputedStyle(el).position === 'static') { + el.style.position = 'relative'; + } + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-btn'; + copyBtn.textContent = 'Copy'; + copyBtn.addEventListener('click', function(e) { + e.stopPropagation(); + const textToCopy = el.textContent.replace(/^Copy$/, '').trim(); + copyToClipboard(textToCopy).then(function() { + copyBtn.textContent = 'Copied!'; + copyBtn.classList.add('copied'); + setTimeout(function() { + copyBtn.textContent = 'Copy'; + copyBtn.classList.remove('copied'); + }, 2000); + }).catch(function(err) { + console.error('Failed to copy:', err); + copyBtn.textContent = 'Failed'; + setTimeout(function() { copyBtn.textContent = 'Copy'; }, 2000); + }); + }); + el.appendChild(copyBtn); +}); +// Add copy functionality to cell headers +document.querySelectorAll('.cell-copy-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + e.preventDefault(); + // Use raw content from data attribute if available, otherwise fall back to textContent + var textToCopy; + if (btn.dataset.copyContent) { + textToCopy = btn.dataset.copyContent; + } else { + const cell = btn.closest('.cell'); + const content = cell.querySelector('.cell-content'); + textToCopy = content.textContent.trim(); + } + copyToClipboard(textToCopy).then(function() { + btn.textContent = 'Copied!'; + btn.classList.add('copied'); + setTimeout(function() { + btn.textContent = 'Copy'; + btn.classList.remove('copied'); + }, 2000); + }).catch(function(err) { + console.error('Failed to copy cell:', err); + btn.textContent = 'Failed'; + setTimeout(function() { btn.textContent = 'Copy'; }, 2000); + }); + }); + // Keyboard accessibility + btn.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.click(); + } + }); +}); +// Tab-style view toggle for tool calls/results +document.querySelectorAll('.view-toggle:not(.cell-view-toggle)').forEach(function(toggle) { + toggle.querySelectorAll('.view-toggle-tab').forEach(function(tab) { + tab.addEventListener('click', function(e) { + e.stopPropagation(); + var container = toggle.closest('.tool-use, .tool-result, .file-tool, .todo-list'); + var viewType = tab.dataset.view; + + // Update active tab styling + toggle.querySelectorAll('.view-toggle-tab').forEach(function(t) { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + }); + tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); + + // Toggle view class + if (viewType === 'json') { + container.classList.add('show-json'); + } else { + container.classList.remove('show-json'); + } + }); + }); +}); +// Cell-level master toggle for all subcells +document.querySelectorAll('.cell-view-toggle').forEach(function(toggle) { + toggle.querySelectorAll('.view-toggle-tab').forEach(function(tab) { + tab.addEventListener('click', function(e) { + e.stopPropagation(); + var cell = toggle.closest('.cell'); + var viewType = tab.dataset.view; + + // Update active tab styling on master toggle + toggle.querySelectorAll('.view-toggle-tab').forEach(function(t) { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + }); + tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); + + // Propagate to all child elements + cell.querySelectorAll('.tool-use, .tool-result, .file-tool, .todo-list').forEach(function(container) { + if (viewType === 'json') { + container.classList.add('show-json'); + } else { + container.classList.remove('show-json'); + } + // Update child toggle tabs + container.querySelectorAll('.view-toggle-tab').forEach(function(childTab) { + childTab.classList.remove('active'); + childTab.setAttribute('aria-selected', 'false'); + if (childTab.dataset.view === viewType) { + childTab.classList.add('active'); + childTab.setAttribute('aria-selected', 'true'); + } + }); + }); + }); + }); +}); +""" + +# JavaScript to fix relative URLs when served via gistpreview.github.io +GIST_PREVIEW_JS = r""" +(function() { + if (window.location.hostname !== 'gistpreview.github.io') return; + // URL format: https://gistpreview.github.io/?GIST_ID/filename.html + var match = window.location.search.match(/^\?([^/]+)/); + if (!match) return; + var gistId = match[1]; + document.querySelectorAll('a[href]').forEach(function(link) { + var href = link.getAttribute('href'); + // Skip external links and anchors + if (href.startsWith('http') || href.startsWith('#') || href.startsWith('//')) return; + // Handle anchor in relative URL (e.g., page-001.html#msg-123) + var parts = href.split('#'); + var filename = parts[0]; + var anchor = parts.length > 1 ? '#' + parts[1] : ''; + link.setAttribute('href', '?' + gistId + '/' + filename + anchor); + }); + + // Handle fragment navigation after dynamic content loads + // gistpreview.github.io loads content dynamically, so the browser's + // native fragment navigation fails because the element doesn't exist yet + function scrollToFragment() { + var hash = window.location.hash; + if (!hash) return false; + var targetId = hash.substring(1); + var target = document.getElementById(targetId); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return true; + } + return false; + } + + // Try immediately in case content is already loaded + if (!scrollToFragment()) { + // Retry with increasing delays to handle dynamic content loading + var delays = [100, 300, 500, 1000]; + delays.forEach(function(delay) { + setTimeout(scrollToFragment, delay); + }); + } +})(); +""" + + +def inject_gist_preview_js(output_dir): + """Inject gist preview JavaScript into all HTML files in the output directory.""" + output_dir = Path(output_dir) + for html_file in output_dir.glob("*.html"): + content = html_file.read_text(encoding="utf-8") + # Insert the gist preview JS before the closing tag + if "" in content: + content = content.replace( + "", f"\n" + ) + html_file.write_text(content, encoding="utf-8") + + +def create_gist(output_dir, public=False): + """Create a GitHub gist from the HTML files in output_dir. + + Returns the gist ID on success, or raises click.ClickException on failure. + """ + output_dir = Path(output_dir) + html_files = list(output_dir.glob("*.html")) + if not html_files: + raise click.ClickException("No HTML files found to upload to gist.") + + # Build the gh gist create command + # gh gist create file1 file2 ... --public/--private + cmd = ["gh", "gist", "create"] + cmd.extend(str(f) for f in sorted(html_files)) + if public: + cmd.append("--public") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + ) + # Output is the gist URL, e.g., https://gist.github.com/username/GIST_ID + gist_url = result.stdout.strip() + # Extract gist ID from URL + gist_id = gist_url.rstrip("/").split("/")[-1] + return gist_id, gist_url + except subprocess.CalledProcessError as e: + error_msg = e.stderr.strip() if e.stderr else str(e) + raise click.ClickException(f"Failed to create gist: {error_msg}") + except FileNotFoundError: + raise click.ClickException( + "gh CLI not found. Install it from https://cli.github.com/ and run 'gh auth login'." + ) + + +def generate_pagination_html(current_page, total_pages): + return _macros.pagination(current_page, total_pages) + + +def generate_index_pagination_html(total_pages): + """Generate pagination for index page where Index is current (first page).""" + return _macros.index_pagination(total_pages) + + +def generate_html(json_path, output_dir, github_repo=None): + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + # Load session file (supports both JSON and JSONL) + data = parse_session_file(json_path) + + loglines = data.get("loglines", []) + + # Auto-detect GitHub repo if not provided + if github_repo is None: + github_repo = detect_github_repo(loglines) + if github_repo: + print(f"Auto-detected GitHub repo: {github_repo}") + else: + print( + "Warning: Could not auto-detect GitHub repo. Commit links will be disabled." + ) + + # Set thread-safe context variable for render functions + set_github_repo(github_repo) + + conversations = [] + current_conv = None + for entry in loglines: + log_type = entry.get("type") + timestamp = entry.get("timestamp", "") + is_compact_summary = entry.get("isCompactSummary", False) + message_data = entry.get("message", {}) + if not message_data: + continue + # Convert message dict to JSON string for compatibility with existing render functions + message_json = json.dumps(message_data) + is_user_prompt = False + user_text = None + if log_type == "user": + content = message_data.get("content", "") + text = extract_text_from_content(content) + if text: + is_user_prompt = True + user_text = text + if is_user_prompt: + if current_conv: + conversations.append(current_conv) + current_conv = { + "user_text": user_text, + "timestamp": timestamp, + "messages": [(log_type, message_json, timestamp)], + "is_continuation": bool(is_compact_summary), + } + elif current_conv: + current_conv["messages"].append((log_type, message_json, timestamp)) + if current_conv: + conversations.append(current_conv) + + total_convs = len(conversations) + total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE + + for page_num in range(1, total_pages + 1): + start_idx = (page_num - 1) * PROMPTS_PER_PAGE + end_idx = min(start_idx + PROMPTS_PER_PAGE, total_convs) + page_convs = conversations[start_idx:end_idx] + messages_html = [] + for conv in page_convs: + is_first = True + parsed_messages = [] + for log_type, message_json, timestamp in conv["messages"]: + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + continue + parsed_messages.append((log_type, message_data, timestamp)) + tool_result_lookup = {} + for log_type, message_data, _ in parsed_messages: + content = message_data.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_result" + and block.get("tool_use_id") + ): + tool_id = block.get("tool_use_id") + if tool_id not in tool_result_lookup: + tool_result_lookup[tool_id] = block + paired_tool_ids = set() + for log_type, message_data, timestamp in parsed_messages: + msg_html = render_message_with_tool_pairs( + log_type, + message_data, + timestamp, + tool_result_lookup, + paired_tool_ids, + ) + if msg_html: + # Wrap continuation summaries in collapsed details + if is_first and conv.get("is_continuation"): + msg_html = f'
    Session continuation summary{msg_html}
    ' + messages_html.append(msg_html) + is_first = False + pagination_html = generate_pagination_html(page_num, total_pages) + page_template = get_template("page.html") + page_content = page_template.render( + css=CSS, + js=JS, + page_num=page_num, + total_pages=total_pages, + pagination_html=pagination_html, + messages_html="".join(messages_html), + ) + (output_dir / f"page-{page_num:03d}.html").write_text( + page_content, encoding="utf-8" + ) + print(f"Generated page-{page_num:03d}.html") + + # Calculate overall stats and collect all commits for timeline + total_tool_counts = {} + total_messages = 0 + all_commits = [] # (timestamp, hash, message, page_num, conv_index) + for i, conv in enumerate(conversations): + total_messages += len(conv["messages"]) + stats = analyze_conversation(conv["messages"]) + for tool, count in stats["tool_counts"].items(): + total_tool_counts[tool] = total_tool_counts.get(tool, 0) + count + page_num = (i // PROMPTS_PER_PAGE) + 1 + for commit_hash, commit_msg, commit_ts in stats["commits"]: + all_commits.append((commit_ts, commit_hash, commit_msg, page_num, i)) + total_tool_calls = sum(total_tool_counts.values()) + total_commits = len(all_commits) + + # Build timeline items: prompts and commits merged by timestamp + timeline_items = [] + + # Add prompts + prompt_num = 0 + for i, conv in enumerate(conversations): + if conv.get("is_continuation"): + continue + if conv["user_text"].startswith("Stop hook feedback:"): + continue + prompt_num += 1 + page_num = (i // PROMPTS_PER_PAGE) + 1 + msg_id = make_msg_id(conv["timestamp"]) + link = f"page-{page_num:03d}.html#{msg_id}" + rendered_content = render_markdown_text(conv["user_text"]) + + # Collect all messages including from subsequent continuation conversations + # This ensures long_texts from continuations appear with the original prompt + all_messages = list(conv["messages"]) + for j in range(i + 1, len(conversations)): + if not conversations[j].get("is_continuation"): + break + all_messages.extend(conversations[j]["messages"]) + + # Analyze conversation for stats (excluding commits from inline display now) + stats = analyze_conversation(all_messages) + tool_stats_str = format_tool_stats(stats["tool_counts"]) + + long_texts_html = "" + for lt in stats["long_texts"]: + rendered_lt = render_markdown_text(lt) + long_texts_html += _macros.index_long_text(rendered_lt) + + stats_html = _macros.index_stats(tool_stats_str, long_texts_html) + + item_html = _macros.index_item( + prompt_num, link, conv["timestamp"], rendered_content, stats_html + ) + timeline_items.append((conv["timestamp"], "prompt", item_html)) + + # Add commits as separate timeline items + for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: + item_html = _macros.index_commit( + commit_hash, commit_msg, commit_ts, get_github_repo() + ) + timeline_items.append((commit_ts, "commit", item_html)) + + # Sort by timestamp + timeline_items.sort(key=lambda x: x[0]) + index_items = [item[2] for item in timeline_items] + + index_pagination = generate_index_pagination_html(total_pages) + index_template = get_template("index.html") + index_content = index_template.render( + css=CSS, + js=JS, + pagination_html=index_pagination, + prompt_num=prompt_num, + total_messages=total_messages, + total_tool_calls=total_tool_calls, + total_commits=total_commits, + total_pages=total_pages, + index_items_html="".join(index_items), + ) + index_path = output_dir / "index.html" + index_path.write_text(index_content, encoding="utf-8") + print( + f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)" + ) + + +@click.group(cls=DefaultGroup, default="local", default_if_no_args=True) +@click.version_option(None, "-v", "--version", package_name="claude-code-transcripts") +def cli(): + """Convert Claude Code session JSON to mobile-friendly HTML pages.""" + pass + + +@cli.command("local") +@click.option( + "-o", + "--output", + type=click.Path(), + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on session filename (uses -o as parent, or current dir).", +) +@click.option( + "--repo", + help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", +) +@click.option( + "--gist", + is_flag=True, + help="Upload to GitHub Gist and output a gistpreview.github.io URL.", +) +@click.option( + "--json", + "include_json", + is_flag=True, + help="Include the original JSONL session file in the output directory.", +) +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated index.html in your default browser (default if no -o specified).", +) +@click.option( + "--limit", + default=10, + help="Maximum number of sessions to show (default: 10)", +) +def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit): + """Select and convert a local Claude Code session to HTML.""" + projects_folder = Path.home() / ".claude" / "projects" + + if not projects_folder.exists(): + click.echo(f"Projects folder not found: {projects_folder}") + click.echo("No local Claude Code sessions available.") + return + + click.echo("Loading local sessions...") + results = find_local_sessions(projects_folder, limit=limit) + + if not results: + click.echo("No local sessions found.") + return + + # Build choices for questionary + choices = [] + for filepath, summary in results: + stat = filepath.stat() + mod_time = datetime.fromtimestamp(stat.st_mtime) + size_kb = stat.st_size / 1024 + date_str = mod_time.strftime("%Y-%m-%d %H:%M") + # Truncate summary if too long + if len(summary) > 50: + summary = summary[:47] + "..." + display = f"{date_str} {size_kb:5.0f} KB {summary}" + choices.append(questionary.Choice(title=display, value=filepath)) + + selected = questionary.select( + "Select a session to convert:", + choices=choices, + ).ask() + + if selected is None: + click.echo("No session selected.") + return + + session_file = selected + + # Determine output directory and whether to open browser + # If no -o specified, use temp dir and open browser by default + auto_open = output is None and not gist and not output_auto + if output_auto: + # Use -o as parent dir (or current dir), with auto-named subdirectory + parent_dir = Path(output) if output else Path(".") + output = parent_dir / session_file.stem + elif output is None: + output = Path(tempfile.gettempdir()) / f"claude-session-{session_file.stem}" + + output = Path(output) + generate_html(session_file, output, github_repo=repo) + + # Show output directory + click.echo(f"Output: {output.resolve()}") + + # Copy JSONL file to output directory if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / session_file.name + shutil.copy(session_file, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSONL: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + + +def is_url(path): + """Check if a path is a URL (starts with http:// or https://).""" + return path.startswith("http://") or path.startswith("https://") + + +def fetch_url_to_tempfile(url): + """Fetch a URL and save to a temporary file. + + Returns the Path to the temporary file. + Raises click.ClickException on network errors. + """ + try: + response = httpx.get(url, timeout=60.0, follow_redirects=True) + response.raise_for_status() + except httpx.RequestError as e: + raise click.ClickException(f"Failed to fetch URL: {e}") + except httpx.HTTPStatusError as e: + raise click.ClickException( + f"Failed to fetch URL: {e.response.status_code} {e.response.reason_phrase}" + ) + + # Determine file extension from URL + url_path = url.split("?")[0] # Remove query params + if url_path.endswith(".jsonl"): + suffix = ".jsonl" + elif url_path.endswith(".json"): + suffix = ".json" + else: + suffix = ".jsonl" # Default to JSONL + + # Extract a name from the URL for the temp file + url_name = Path(url_path).stem or "session" + + temp_dir = Path(tempfile.gettempdir()) + temp_file = temp_dir / f"claude-url-{url_name}{suffix}" + temp_file.write_text(response.text, encoding="utf-8") + return temp_file + + +@cli.command("json") +@click.argument("json_file", type=click.Path()) +@click.option( + "-o", + "--output", + type=click.Path(), + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on filename (uses -o as parent, or current dir).", +) +@click.option( + "--repo", + help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", +) +@click.option( + "--gist", + is_flag=True, + help="Upload to GitHub Gist and output a gistpreview.github.io URL.", +) +@click.option( + "--json", + "include_json", + is_flag=True, + help="Include the original JSON session file in the output directory.", +) +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated index.html in your default browser (default if no -o specified).", +) +def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_browser): + """Convert a Claude Code session JSON/JSONL file or URL to HTML.""" + # Handle URL input + if is_url(json_file): + click.echo(f"Fetching {json_file}...") + temp_file = fetch_url_to_tempfile(json_file) + json_file_path = temp_file + # Use URL path for naming + url_name = Path(json_file.split("?")[0]).stem or "session" + else: + # Validate that local file exists + json_file_path = Path(json_file) + if not json_file_path.exists(): + raise click.ClickException(f"File not found: {json_file}") + url_name = None + + # Determine output directory and whether to open browser + # If no -o specified, use temp dir and open browser by default + auto_open = output is None and not gist and not output_auto + if output_auto: + # Use -o as parent dir (or current dir), with auto-named subdirectory + parent_dir = Path(output) if output else Path(".") + output = parent_dir / (url_name or json_file_path.stem) + elif output is None: + output = ( + Path(tempfile.gettempdir()) + / f"claude-session-{url_name or json_file_path.stem}" + ) + + output = Path(output) + generate_html(json_file_path, output, github_repo=repo) + + # Show output directory + click.echo(f"Output: {output.resolve()}") + + # Copy JSON file to output directory if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / json_file_path.name + shutil.copy(json_file_path, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + + +def resolve_credentials(token, org_uuid): + """Resolve token and org_uuid from arguments or auto-detect. + + Returns (token, org_uuid) tuple. + Raises click.ClickException if credentials cannot be resolved. + """ + # Get token + if token is None: + token = get_access_token_from_keychain() + if token is None: + if platform.system() == "Darwin": + raise click.ClickException( + "Could not retrieve access token from macOS keychain. " + "Make sure you are logged into Claude Code, or provide --token." + ) + else: + raise click.ClickException( + "On non-macOS platforms, you must provide --token with your access token." + ) + + # Get org UUID + if org_uuid is None: + org_uuid = get_org_uuid_from_config() + if org_uuid is None: + raise click.ClickException( + "Could not find organization UUID in ~/.claude.json. " + "Provide --org-uuid with your organization UUID." + ) + + return token, org_uuid + + +def format_session_for_display(session_data): + """Format a session for display in the list or picker. + + Returns a formatted string. + """ + session_id = session_data.get("id", "unknown") + title = session_data.get("title", "Untitled") + created_at = session_data.get("created_at", "") + # Truncate title if too long + if len(title) > 60: + title = title[:57] + "..." + return f"{session_id} {created_at[:19] if created_at else 'N/A':19} {title}" + + +def generate_html_from_session_data(session_data, output_dir, github_repo=None): + """Generate HTML from session data dict (instead of file path).""" + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + + loglines = session_data.get("loglines", []) + + # Auto-detect GitHub repo if not provided + if github_repo is None: + github_repo = detect_github_repo(loglines) + if github_repo: + click.echo(f"Auto-detected GitHub repo: {github_repo}") + + # Set thread-safe context variable for render functions + set_github_repo(github_repo) + + conversations = [] + current_conv = None + for entry in loglines: + log_type = entry.get("type") + timestamp = entry.get("timestamp", "") + is_compact_summary = entry.get("isCompactSummary", False) + message_data = entry.get("message", {}) + if not message_data: + continue + # Convert message dict to JSON string for compatibility with existing render functions + message_json = json.dumps(message_data) + is_user_prompt = False + user_text = None + if log_type == "user": + content = message_data.get("content", "") + text = extract_text_from_content(content) + if text: + is_user_prompt = True + user_text = text + if is_user_prompt: + if current_conv: + conversations.append(current_conv) + current_conv = { + "user_text": user_text, + "timestamp": timestamp, + "messages": [(log_type, message_json, timestamp)], + "is_continuation": bool(is_compact_summary), + } + elif current_conv: + current_conv["messages"].append((log_type, message_json, timestamp)) + if current_conv: + conversations.append(current_conv) + + total_convs = len(conversations) + total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE + + for page_num in range(1, total_pages + 1): + start_idx = (page_num - 1) * PROMPTS_PER_PAGE + end_idx = min(start_idx + PROMPTS_PER_PAGE, total_convs) + page_convs = conversations[start_idx:end_idx] + messages_html = [] + for conv in page_convs: + is_first = True + parsed_messages = [] + for log_type, message_json, timestamp in conv["messages"]: + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + continue + parsed_messages.append((log_type, message_data, timestamp)) + tool_result_lookup = {} + for log_type, message_data, _ in parsed_messages: + content = message_data.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_result" + and block.get("tool_use_id") + ): + tool_id = block.get("tool_use_id") + if tool_id not in tool_result_lookup: + tool_result_lookup[tool_id] = block + paired_tool_ids = set() + for log_type, message_data, timestamp in parsed_messages: + msg_html = render_message_with_tool_pairs( + log_type, + message_data, + timestamp, + tool_result_lookup, + paired_tool_ids, + ) + if msg_html: + # Wrap continuation summaries in collapsed details + if is_first and conv.get("is_continuation"): + msg_html = f'
    Session continuation summary{msg_html}
    ' + messages_html.append(msg_html) + is_first = False + pagination_html = generate_pagination_html(page_num, total_pages) + page_template = get_template("page.html") + page_content = page_template.render( + css=CSS, + js=JS, + page_num=page_num, + total_pages=total_pages, + pagination_html=pagination_html, + messages_html="".join(messages_html), + ) + (output_dir / f"page-{page_num:03d}.html").write_text( + page_content, encoding="utf-8" + ) + click.echo(f"Generated page-{page_num:03d}.html") + + # Calculate overall stats and collect all commits for timeline + total_tool_counts = {} + total_messages = 0 + all_commits = [] # (timestamp, hash, message, page_num, conv_index) + for i, conv in enumerate(conversations): + total_messages += len(conv["messages"]) + stats = analyze_conversation(conv["messages"]) + for tool, count in stats["tool_counts"].items(): + total_tool_counts[tool] = total_tool_counts.get(tool, 0) + count + page_num = (i // PROMPTS_PER_PAGE) + 1 + for commit_hash, commit_msg, commit_ts in stats["commits"]: + all_commits.append((commit_ts, commit_hash, commit_msg, page_num, i)) + total_tool_calls = sum(total_tool_counts.values()) + total_commits = len(all_commits) + + # Build timeline items: prompts and commits merged by timestamp + timeline_items = [] + + # Add prompts + prompt_num = 0 + for i, conv in enumerate(conversations): + if conv.get("is_continuation"): + continue + if conv["user_text"].startswith("Stop hook feedback:"): + continue + prompt_num += 1 + page_num = (i // PROMPTS_PER_PAGE) + 1 + msg_id = make_msg_id(conv["timestamp"]) + link = f"page-{page_num:03d}.html#{msg_id}" + rendered_content = render_markdown_text(conv["user_text"]) + + # Collect all messages including from subsequent continuation conversations + # This ensures long_texts from continuations appear with the original prompt + all_messages = list(conv["messages"]) + for j in range(i + 1, len(conversations)): + if not conversations[j].get("is_continuation"): + break + all_messages.extend(conversations[j]["messages"]) + + # Analyze conversation for stats (excluding commits from inline display now) + stats = analyze_conversation(all_messages) + tool_stats_str = format_tool_stats(stats["tool_counts"]) + + long_texts_html = "" + for lt in stats["long_texts"]: + rendered_lt = render_markdown_text(lt) + long_texts_html += _macros.index_long_text(rendered_lt) + + stats_html = _macros.index_stats(tool_stats_str, long_texts_html) + + item_html = _macros.index_item( + prompt_num, link, conv["timestamp"], rendered_content, stats_html + ) + timeline_items.append((conv["timestamp"], "prompt", item_html)) + + # Add commits as separate timeline items + for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: + item_html = _macros.index_commit( + commit_hash, commit_msg, commit_ts, get_github_repo() + ) + timeline_items.append((commit_ts, "commit", item_html)) + + # Sort by timestamp + timeline_items.sort(key=lambda x: x[0]) + index_items = [item[2] for item in timeline_items] + + index_pagination = generate_index_pagination_html(total_pages) + index_template = get_template("index.html") + index_content = index_template.render( + css=CSS, + js=JS, + pagination_html=index_pagination, + prompt_num=prompt_num, + total_messages=total_messages, + total_tool_calls=total_tool_calls, + total_commits=total_commits, + total_pages=total_pages, + index_items_html="".join(index_items), + ) + index_path = output_dir / "index.html" + index_path.write_text(index_content, encoding="utf-8") + click.echo( + f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)" + ) + + +@cli.command("web") +@click.argument("session_id", required=False) +@click.option( + "-o", + "--output", + type=click.Path(), + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on session ID (uses -o as parent, or current dir).", +) +@click.option("--token", help="API access token (auto-detected from keychain on macOS)") +@click.option( + "--org-uuid", help="Organization UUID (auto-detected from ~/.claude.json)" +) +@click.option( + "--repo", + help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", +) +@click.option( + "--gist", + is_flag=True, + help="Upload to GitHub Gist and output a gistpreview.github.io URL.", +) +@click.option( + "--json", + "include_json", + is_flag=True, + help="Include the JSON session data in the output directory.", +) +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated index.html in your default browser (default if no -o specified).", +) +def web_cmd( + session_id, + output, + output_auto, + token, + org_uuid, + repo, + gist, + include_json, + open_browser, +): + """Select and convert a web session from the Claude API to HTML. + + If SESSION_ID is not provided, displays an interactive picker to select a session. + """ + try: + token, org_uuid = resolve_credentials(token, org_uuid) + except click.ClickException: + raise + + # If no session ID provided, show interactive picker + if session_id is None: + try: + sessions_data = fetch_sessions(token, org_uuid) + except httpx.HTTPStatusError as e: + raise click.ClickException( + f"API request failed: {e.response.status_code} {e.response.text}" + ) + except httpx.RequestError as e: + raise click.ClickException(f"Network error: {e}") + + sessions = sessions_data.get("data", []) + if not sessions: + raise click.ClickException("No sessions found.") + + # Build choices for questionary + choices = [] + for s in sessions: + sid = s.get("id", "unknown") + title = s.get("title", "Untitled") + created_at = s.get("created_at", "") + # Truncate title if too long + if len(title) > 50: + title = title[:47] + "..." + display = f"{created_at[:19] if created_at else 'N/A':19} {title}" + choices.append(questionary.Choice(title=display, value=sid)) + + selected = questionary.select( + "Select a session to import:", + choices=choices, + ).ask() + + if selected is None: + # User cancelled + raise click.ClickException("No session selected.") + + session_id = selected + + # Fetch the session + click.echo(f"Fetching session {session_id}...") + try: + session_data = fetch_session(token, org_uuid, session_id) + except httpx.HTTPStatusError as e: + raise click.ClickException( + f"API request failed: {e.response.status_code} {e.response.text}" + ) + except httpx.RequestError as e: + raise click.ClickException(f"Network error: {e}") + + # Determine output directory and whether to open browser + # If no -o specified, use temp dir and open browser by default + auto_open = output is None and not gist and not output_auto + if output_auto: + # Use -o as parent dir (or current dir), with auto-named subdirectory + parent_dir = Path(output) if output else Path(".") + output = parent_dir / session_id + elif output is None: + output = Path(tempfile.gettempdir()) / f"claude-session-{session_id}" + + output = Path(output) + click.echo(f"Generating HTML in {output}/...") + generate_html_from_session_data(session_data, output, github_repo=repo) + + # Show output directory + click.echo(f"Output: {output.resolve()}") + + # Save JSON session data if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / f"{session_id}.json" + with open(json_dest, "w") as f: + json.dump(session_data, f, indent=2) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + + +@cli.command("all") +@click.option( + "-s", + "--source", + type=click.Path(exists=True), + help="Source directory containing Claude projects (default: ~/.claude/projects).", +) +@click.option( + "-o", + "--output", + type=click.Path(), + default="./claude-archive", + help="Output directory for the archive (default: ./claude-archive).", +) +@click.option( + "--include-agents", + is_flag=True, + help="Include agent-* session files (excluded by default).", +) +@click.option( + "--dry-run", + is_flag=True, + help="Show what would be converted without creating files.", +) +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated archive in your default browser.", +) +@click.option( + "-q", + "--quiet", + is_flag=True, + help="Suppress all output except errors.", +) +def all_cmd(source, output, include_agents, dry_run, open_browser, quiet): + """Convert all local Claude Code sessions to a browsable HTML archive. + + Creates a directory structure with: + - Master index listing all projects + - Per-project pages listing sessions + - Individual session transcripts + """ + # Default source folder + if source is None: + source = Path.home() / ".claude" / "projects" + else: + source = Path(source) + + if not source.exists(): + raise click.ClickException(f"Source directory not found: {source}") + + output = Path(output) + + if not quiet: + click.echo(f"Scanning {source}...") + + projects = find_all_sessions(source, include_agents=include_agents) + + if not projects: + if not quiet: + click.echo("No sessions found.") + return + + # Calculate totals + total_sessions = sum(len(p["sessions"]) for p in projects) + + if not quiet: + click.echo(f"Found {len(projects)} projects with {total_sessions} sessions") + + if dry_run: + # Dry-run always outputs (it's the point of dry-run), but respects --quiet + if not quiet: + click.echo("\nDry run - would convert:") + for project in projects: + click.echo( + f"\n {project['name']} ({len(project['sessions'])} sessions)" + ) + for session in project["sessions"][:3]: # Show first 3 + mod_time = datetime.fromtimestamp(session["mtime"]) + click.echo( + f" - {session['path'].stem} ({mod_time.strftime('%Y-%m-%d')})" + ) + if len(project["sessions"]) > 3: + click.echo(f" ... and {len(project['sessions']) - 3} more") + return + + if not quiet: + click.echo(f"\nGenerating archive in {output}...") + + # Progress callback for non-quiet mode + def on_progress(project_name, session_name, current, total): + if not quiet and current % 10 == 0: + click.echo(f" Processed {current}/{total} sessions...") + + # Generate the archive using the library function + stats = generate_batch_html( + source, + output, + include_agents=include_agents, + progress_callback=on_progress, + ) + + # Report any failures + if stats["failed_sessions"]: + click.echo(f"\nWarning: {len(stats['failed_sessions'])} session(s) failed:") + for failure in stats["failed_sessions"]: + click.echo( + f" {failure['project']}/{failure['session']}: {failure['error']}" + ) + + if not quiet: + click.echo( + f"\nGenerated archive with {stats['total_projects']} projects, " + f"{stats['total_sessions']} sessions" + ) + click.echo(f"Output: {output.resolve()}") + + if open_browser: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + + +def main(): + # print("RUNNING LOCAL VERSION!!") + cli() diff --git a/src/claude_code_transcripts/templates/base.html b/src/claude_code_transcripts/templates/base.html new file mode 100644 index 0000000..aa833f0 --- /dev/null +++ b/src/claude_code_transcripts/templates/base.html @@ -0,0 +1,15 @@ + + + + + + {% block title %}Claude Code transcript{% endblock %} + + + +
    +{%- block content %}{% endblock %} +
    + + + \ No newline at end of file diff --git a/src/claude_code_transcripts/templates/index.html b/src/claude_code_transcripts/templates/index.html new file mode 100644 index 0000000..30ed6ea --- /dev/null +++ b/src/claude_code_transcripts/templates/index.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Claude Code transcript - Index{% endblock %} + +{% block content %} +
    +

    Claude Code transcript

    + +
    + {{ pagination_html|safe }} +

    {{ prompt_num }} prompts · {{ total_messages }} messages · {{ total_tool_calls }} tool calls · {{ total_commits }} commits · {{ total_pages }} pages

    + {{ index_items_html|safe }} + {{ pagination_html|safe }} + + +
    + + + +
    +
    +
    +
    + +{%- endblock %} \ No newline at end of file diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html new file mode 100644 index 0000000..ae369bd --- /dev/null +++ b/src/claude_code_transcripts/templates/macros.html @@ -0,0 +1,268 @@ +{# Pagination for regular pages #} +{% macro pagination(current_page, total_pages) %} +{% if total_pages <= 1 %} + +{%- else %} + +{%- endif %} +{% endmacro %} + +{# Pagination for index page #} +{% macro index_pagination(total_pages) %} +{% if total_pages < 1 %} + +{%- else %} + +{%- endif %} +{% endmacro %} + +{# Todo list - input_json_html is pre-rendered JSON so needs |safe #} +{% macro todo_list(todos, input_json_html, tool_id) %} +
    +
    Task List +
    + + +
    +
    +
    + +
    +
    +{{ input_json_html|safe }} +
    +
    +{%- endmacro %} + +{# Write tool - content is pre-highlighted so needs |safe, input_json_html is pre-rendered JSON so needs |safe #} +{% macro write_tool(file_path, content, input_json_html, tool_id) %} +{%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%} +
    +
    📝 Write {{ filename }} +
    + + +
    +
    +
    +
    {{ file_path }}
    +
    {{ content|safe }}
    +
    +
    +{{ input_json_html|safe }} +
    +
    +{%- endmacro %} + +{# Edit tool - old/new strings are pre-highlighted so need |safe, input_json_html is pre-rendered JSON so needs |safe #} +{% macro edit_tool(file_path, old_string, new_string, replace_all, input_json_html, tool_id) %} +{%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%} +
    +
    ✏️ Edit {{ filename }}{% if replace_all %} (replace all){% endif %} +
    + + +
    +
    +
    +
    {{ file_path }}
    +
    +
    {{ old_string|safe }}
    +
    +
    {{ new_string|safe }}
    +
    +
    +
    +{{ input_json_html|safe }} +
    +
    +{%- endmacro %} + +{# Bash tool - description_html is pre-rendered markdown so needs |safe, input_json_html is pre-rendered JSON so needs |safe #} +{% macro bash_tool(command, description_html, input_json_html, tool_id) %} +
    +
    Call$ Bash +
    + + +
    +
    +
    +{%- if description_html %} +
    {{ description_html|safe }}
    +{%- endif -%} +
    {{ command }}
    +
    +
    +{{ input_json_html|safe }} +
    +
    +{%- endmacro %} + +{# Generic tool use - description_html, input_markdown_html, input_json_html are pre-rendered so need |safe #} +{% macro tool_use(tool_name, tool_icon, description_html, input_markdown_html, input_json_html, tool_id) %} +
    Call{{ tool_icon }} {{ tool_name }}
    +{%- if description_html -%} +
    {{ description_html|safe }}
    +{%- endif -%} +
    {{ input_markdown_html|safe }}
    {{ input_json_html|safe }}
    +{%- endmacro %} + +{# Tool result - content_markdown_html and content_json_html are pre-rendered so need |safe #} +{% macro tool_result(content_markdown_html, content_json_html, is_error) %} +{%- set error_class = ' tool-error' if is_error else '' -%} +
    {% if is_error %} Error{% else %} Result{% endif %}
    {{ content_markdown_html|safe }}
    {{ content_json_html|safe }}
    +{%- endmacro %} + +{# Tool pair wrapper - tool_use_html/tool_result_html are pre-rendered #} +{% macro tool_pair(tool_use_html, tool_result_html) %} +
    {{ tool_use_html|safe }}{{ tool_result_html|safe }}
    +{%- endmacro %} + +{# Collapsible cell wrapper for message sections #} +{% macro cell(cell_type, label, content_html, open_by_default=false, count=0, raw_content="") %} +
    + +{{ label }}{% if count %} ({{ count }}){% endif %} +{% if cell_type == 'tools' %} +
    + + +
    +{% endif %} + +
    +
    {{ content_html|safe }}
    +
    +{%- endmacro %} + +{# Thinking block - content_html is pre-rendered markdown so needs |safe #} +{% macro thinking(content_html) %} +
    Thinking
    {{ content_html|safe }}
    +{%- endmacro %} + +{# Assistant text - content_html is pre-rendered markdown so needs |safe #} +{% macro assistant_text(content_html) %} +
    {{ content_html|safe }}
    +{%- endmacro %} + +{# User content - content_html is pre-rendered so needs |safe #} +{% macro user_content(content_html) %} +
    {{ content_html|safe }}
    +{%- endmacro %} + +{# Image block with base64 data URL #} +{% macro image_block(media_type, data) %} +
    +{%- endmacro %} + +{# Commit card (in tool results) #} +{% macro commit_card(commit_hash, commit_msg, github_repo) %} +{%- if github_repo -%} +{%- set github_link = 'https://github.com/' ~ github_repo ~ '/commit/' ~ commit_hash -%} +
    {{ commit_hash[:7] }} {{ commit_msg }}
    +{%- else -%} +
    {{ commit_hash[:7] }} {{ commit_msg }}
    +{%- endif %} +{%- endmacro %} + +{# Message metadata subsection #} +{% macro metadata(char_count, token_estimate, tool_counts) %} +
    +Metadata +
    + + +{%- if tool_counts %} +{%- for tool_name, count in tool_counts.items() %} + +{%- endfor %} +{%- endif %} +
    +
    +{%- endmacro %} + +{# Message wrapper - content_html is pre-rendered so needs |safe, metadata_html is optional #} +{% macro message(role_class, role_label, msg_id, timestamp, content_html, metadata_html="") %} +
    {{ role_label }}
    {{ metadata_html|safe }}
    {{ content_html|safe }}
    +{%- endmacro %} + +{# Continuation wrapper - content_html is pre-rendered so needs |safe #} +{% macro continuation(content_html) %} +
    Session continuation summary{{ content_html|safe }}
    +{%- endmacro %} + +{# Index item (prompt) - rendered_content and stats_html are pre-rendered so need |safe #} +{% macro index_item(prompt_num, link, timestamp, rendered_content, stats_html) %} +
    #{{ prompt_num }}
    {{ rendered_content|safe }}
    {{ stats_html|safe }}
    +{%- endmacro %} + +{# Index commit #} +{% macro index_commit(commit_hash, commit_msg, timestamp, github_repo) %} +{%- if github_repo -%} +{%- set github_link = 'https://github.com/' ~ github_repo ~ '/commit/' ~ commit_hash -%} +
    {{ commit_hash[:7] }}
    {{ commit_msg }}
    +{%- else -%} +
    {{ commit_hash[:7] }}
    {{ commit_msg }}
    +{%- endif %} +{%- endmacro %} + +{# Index stats - tool_stats_str and long_texts_html are pre-rendered so need |safe #} +{% macro index_stats(tool_stats_str, long_texts_html) %} +{%- if tool_stats_str or long_texts_html -%} +
    +{%- if tool_stats_str -%}{{ tool_stats_str }}{%- endif -%} +{{ long_texts_html|safe }} +
    +{%- endif %} +{%- endmacro %} + +{# Long text in index - rendered_content is pre-rendered markdown so needs |safe #} +{% macro index_long_text(rendered_content) %} +
    {{ rendered_content|safe }}
    +{%- endmacro %} diff --git a/src/claude_code_transcripts/templates/master_index.html b/src/claude_code_transcripts/templates/master_index.html new file mode 100644 index 0000000..8b7b8c5 --- /dev/null +++ b/src/claude_code_transcripts/templates/master_index.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block title %}Claude Code Archive{% endblock %} + +{% block content %} +

    Claude Code Archive

    +

    {{ total_projects }} projects · {{ total_sessions }} sessions

    + + {% for project in projects %} +
    + +
    + {{ project.name }} + +
    +
    +

    {{ project.session_count }} session{% if project.session_count != 1 %}s{% endif %}

    +
    +
    +
    + {% endfor %} +{%- endblock %} diff --git a/src/claude_code_transcripts/templates/page.html b/src/claude_code_transcripts/templates/page.html new file mode 100644 index 0000000..eaa4e5f --- /dev/null +++ b/src/claude_code_transcripts/templates/page.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block title %}Claude Code transcript - page {{ page_num }}{% endblock %} + +{% block content %} +

    Claude Code transcript - page {{ page_num }}/{{ total_pages }}

    + {{ pagination_html|safe }} + {{ messages_html|safe }} + {{ pagination_html|safe }} +{%- endblock %} \ No newline at end of file diff --git a/src/claude_code_transcripts/templates/project_index.html b/src/claude_code_transcripts/templates/project_index.html new file mode 100644 index 0000000..3fc5750 --- /dev/null +++ b/src/claude_code_transcripts/templates/project_index.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}{{ project_name }} - Claude Code Archive{% endblock %} + +{% block content %} +

    Claude Code Archive / {{ project_name }}

    +

    {{ session_count }} session{% if session_count != 1 %}s{% endif %}

    + + {% for session in sessions %} +
    + +
    + {{ session.date }} + {{ "%.0f"|format(session.size_kb) }} KB +
    +
    +

    {{ session.summary[:100] }}{% if session.summary|length > 100 %}...{% endif %}

    +
    +
    +
    + {% endfor %} + +
    + +
    +{%- endblock %} diff --git a/src/claude_code_transcripts/templates/search.js b/src/claude_code_transcripts/templates/search.js new file mode 100644 index 0000000..48a6e1d --- /dev/null +++ b/src/claude_code_transcripts/templates/search.js @@ -0,0 +1,277 @@ +(function() { + var totalPages = {{ total_pages }}; + var searchBox = document.getElementById('search-box'); + var searchInput = document.getElementById('search-input'); + var searchBtn = document.getElementById('search-btn'); + var modal = document.getElementById('search-modal'); + var modalInput = document.getElementById('modal-search-input'); + var modalSearchBtn = document.getElementById('modal-search-btn'); + var modalCloseBtn = document.getElementById('modal-close-btn'); + var searchStatus = document.getElementById('search-status'); + var searchResults = document.getElementById('search-results'); + + if (!searchBox || !modal) return; + + // Hide search on file:// protocol (doesn't work due to CORS restrictions) + if (window.location.protocol === 'file:') return; + + // Show search box (progressive enhancement) + searchBox.style.display = 'flex'; + + // Gist preview support - detect if we're on gistpreview.github.io + var isGistPreview = window.location.hostname === 'gistpreview.github.io'; + var gistId = null; + var gistOwner = null; + var gistInfoLoaded = false; + + if (isGistPreview) { + // Extract gist ID from URL query string like ?78a436a8a9e7a2e603738b8193b95410/index.html + var queryMatch = window.location.search.match(/^\?([a-f0-9]+)/i); + if (queryMatch) { + gistId = queryMatch[1]; + } + } + + async function loadGistInfo() { + if (!isGistPreview || !gistId || gistInfoLoaded) return; + try { + var response = await fetch('https://api.github.com/gists/' + gistId); + if (response.ok) { + var info = await response.json(); + gistOwner = info.owner.login; + gistInfoLoaded = true; + } + } catch (e) { + console.error('Failed to load gist info:', e); + } + } + + function getPageFetchUrl(pageFile) { + if (isGistPreview && gistOwner && gistId) { + // Use raw gist URL for fetching content + return 'https://gist.githubusercontent.com/' + gistOwner + '/' + gistId + '/raw/' + pageFile; + } + return pageFile; + } + + function getPageLinkUrl(pageFile) { + if (isGistPreview && gistId) { + // Use gistpreview URL format for navigation links + return '?' + gistId + '/' + pageFile; + } + return pageFile; + } + + function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function openModal(query) { + modalInput.value = query || ''; + searchResults.innerHTML = ''; + searchStatus.textContent = ''; + modal.showModal(); + modalInput.focus(); + if (query) { + performSearch(query); + } + } + + function closeModal() { + modal.close(); + // Update URL to remove search fragment, preserving path and query string + if (window.location.hash.startsWith('#search=')) { + history.replaceState(null, '', window.location.pathname + window.location.search); + } + } + + function updateUrlHash(query) { + if (query) { + // Preserve path and query string when adding hash + history.replaceState(null, '', window.location.pathname + window.location.search + '#search=' + encodeURIComponent(query)); + } + } + + function highlightTextNodes(element, searchTerm) { + var walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false); + var nodesToReplace = []; + + while (walker.nextNode()) { + var node = walker.currentNode; + if (node.nodeValue.toLowerCase().indexOf(searchTerm.toLowerCase()) !== -1) { + nodesToReplace.push(node); + } + } + + nodesToReplace.forEach(function(node) { + var text = node.nodeValue; + var regex = new RegExp('(' + escapeRegex(searchTerm) + ')', 'gi'); + var parts = text.split(regex); + if (parts.length > 1) { + var span = document.createElement('span'); + parts.forEach(function(part) { + if (part.toLowerCase() === searchTerm.toLowerCase()) { + var mark = document.createElement('mark'); + mark.textContent = part; + span.appendChild(mark); + } else { + span.appendChild(document.createTextNode(part)); + } + }); + node.parentNode.replaceChild(span, node); + } + }); + } + + function fixInternalLinks(element, pageFile) { + // Update all internal anchor links to include the page file + var links = element.querySelectorAll('a[href^="#"]'); + links.forEach(function(link) { + var href = link.getAttribute('href'); + link.setAttribute('href', pageFile + href); + }); + } + + function processPage(pageFile, html, query) { + var parser = new DOMParser(); + var doc = parser.parseFromString(html, 'text/html'); + var resultsFromPage = 0; + + // Find all message blocks + var messages = doc.querySelectorAll('.message'); + messages.forEach(function(msg) { + var text = msg.textContent || ''; + if (text.toLowerCase().indexOf(query.toLowerCase()) !== -1) { + resultsFromPage++; + + // Get the message ID for linking + var msgId = msg.id || ''; + var pageLinkUrl = getPageLinkUrl(pageFile); + var link = pageLinkUrl + (msgId ? '#' + msgId : ''); + + // Clone the message HTML and highlight matches + var clone = msg.cloneNode(true); + // Fix internal links to include the page file + fixInternalLinks(clone, pageLinkUrl); + highlightTextNodes(clone, query); + + var resultDiv = document.createElement('div'); + resultDiv.className = 'search-result'; + resultDiv.innerHTML = '' + + '
    ' + escapeHtml(pageFile) + '
    ' + + '
    ' + clone.innerHTML + '
    ' + + '
    '; + searchResults.appendChild(resultDiv); + } + }); + + return resultsFromPage; + } + + async function performSearch(query) { + if (!query.trim()) { + searchStatus.textContent = 'Enter a search term'; + return; + } + + updateUrlHash(query); + searchResults.innerHTML = ''; + searchStatus.textContent = 'Searching...'; + + // Load gist info if on gistpreview (needed for constructing URLs) + if (isGistPreview && !gistInfoLoaded) { + searchStatus.textContent = 'Loading gist info...'; + await loadGistInfo(); + if (!gistOwner) { + searchStatus.textContent = 'Failed to load gist info. Search unavailable.'; + return; + } + } + + var resultsFound = 0; + var pagesSearched = 0; + + // Build list of pages to fetch + var pagesToFetch = []; + for (var i = 1; i <= totalPages; i++) { + pagesToFetch.push('page-' + String(i).padStart(3, '0') + '.html'); + } + + searchStatus.textContent = 'Searching...'; + + // Process pages in batches of 3, but show results immediately as each completes + var batchSize = 3; + for (var i = 0; i < pagesToFetch.length; i += batchSize) { + var batch = pagesToFetch.slice(i, i + batchSize); + + // Create promises that process results immediately when each fetch completes + var promises = batch.map(function(pageFile) { + return fetch(getPageFetchUrl(pageFile)) + .then(function(response) { + if (!response.ok) throw new Error('Failed to fetch'); + return response.text(); + }) + .then(function(html) { + // Process and display results immediately + var count = processPage(pageFile, html, query); + resultsFound += count; + pagesSearched++; + searchStatus.textContent = 'Found ' + resultsFound + ' result(s) in ' + pagesSearched + '/' + totalPages + ' pages...'; + }) + .catch(function() { + pagesSearched++; + searchStatus.textContent = 'Found ' + resultsFound + ' result(s) in ' + pagesSearched + '/' + totalPages + ' pages...'; + }); + }); + + // Wait for this batch to complete before starting the next + await Promise.all(promises); + } + + searchStatus.textContent = 'Found ' + resultsFound + ' result(s) in ' + totalPages + ' pages'; + } + + // Event listeners + searchBtn.addEventListener('click', function() { + openModal(searchInput.value); + }); + + searchInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + openModal(searchInput.value); + } + }); + + modalSearchBtn.addEventListener('click', function() { + performSearch(modalInput.value); + }); + + modalInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + performSearch(modalInput.value); + } + }); + + modalCloseBtn.addEventListener('click', closeModal); + + modal.addEventListener('click', function(e) { + if (e.target === modal) { + closeModal(); + } + }); + + // Check for #search= in URL on page load + if (window.location.hash.startsWith('#search=')) { + var query = decodeURIComponent(window.location.hash.substring(8)); + if (query) { + searchInput.value = query; + openModal(query); + } + } +})(); diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 6c27358..72f000e 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -5,19 +5,102 @@ Claude Code transcript - Index
    -

    Claude Code transcript

    - +
    +
    +
    + +Tool Calls (1) + +
    + + +
    + + +
    +
    +
    +
    +
    Call$ Bash +
    + + +
    +
    +
    +

    Commit the fix

    git add . && git commit -m 'Add subtract function and fix tests'
    +
    +
    +
    {
    +  "command": "git add . && git commit -m 'Add subtract function and fix tests'",
    +  "description": "Commit the fix"
    +}
    +
    +
    Result
    2 files changed, 10 insertions(+), 1 deletion(-)
    [main def5678] Add subtract function and fix tests
    + 2 files changed, 10 insertions(+), 1 deletion(-)
    +
    +
    +
    +
    + +Response + + + +
    +

    Done! The subtract function is now working and committed.

    +
    Session continuation summary +
    +
    +
    + +Message + + + +

    This is a session continuation summary from a previous context. The user was working on a math utilities library.

    +
    + + + +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index abe0d06..141643c 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -5,19 +5,102 @@ Claude Code transcript - page 2

    Claude Code transcript - page 2/2

    - \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html new file mode 100644 index 0000000..4fa8242 --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -0,0 +1,841 @@ + + + + + + Claude Code transcript - Index + + + +
    +
    +

    Claude Code transcript

    + +
    + + + + +

    2 prompts · 7 messages · 2 tool calls · 1 commits · 1 pages

    + +
    abc1234
    Add hello function
    + + + + + + + +
    + + + +
    +
    +
    +
    + +
    + + + \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_image_block.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_image_block.html new file mode 100644 index 0000000..edf8357 --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_image_block.html @@ -0,0 +1,2 @@ + +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_text_block.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_text_block.html index d9c0d26..464525a 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_text_block.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_text_block.html @@ -1 +1,2 @@ +

    Here is my response with markdown.

    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_thinking_block.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_thinking_block.html index 4952a33..47b7baa 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_thinking_block.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_thinking_block.html @@ -1,3 +1,4 @@ +
    Thinking

    Let me think about this...

    1. First consideration
    2. diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html index e4e3501..33e75e1 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html @@ -1,3 +1,5 @@ -
      Command completed successfully
      +
      Result

      Command completed successfully Output line 1 -Output line 2

      \ No newline at end of file +Output line 2

    Command completed successfully
    +Output line 1
    +Output line 2
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html new file mode 100644 index 0000000..dc63dee --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html @@ -0,0 +1,9 @@ +
    Result
    +

    Here is the file content:

    +

    Line 1 +Line 2

    [
    +  {
    +    "type": "text",
    +    "text": "Here is the file content:\n\nLine 1\nLine 2"
    +  }
    +]
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html new file mode 100644 index 0000000..b8b592d --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html @@ -0,0 +1,11 @@ +
    Result
    +
    [
    +  {
    +    "type": "image",
    +    "source": {
    +      "type": "base64",
    +      "media_type": "image/gif",
    +      "data": "R0lGODlhAQABAIAAAAUEBA=="
    +    }
    +  }
    +]
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html new file mode 100644 index 0000000..ba8193c --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html @@ -0,0 +1,28 @@ +
    Result
    +
    +
    Call$ Bash +
    + + +
    +
    +
    +

    List files

    ls -la
    +
    +
    +
    {
    +  "command": "ls -la",
    +  "description": "List files"
    +}
    +
    +
    [
    +  {
    +    "type": "tool_use",
    +    "id": "toolu_123",
    +    "name": "Bash",
    +    "input": {
    +      "command": "ls -la",
    +      "description": "List files"
    +    }
    +  }
    +]
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html index eb4def7..e408b89 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html @@ -1,2 +1,3 @@ -
    Error: file not found
    -Traceback follows...
    \ No newline at end of file +
    Error

    Error: file not found +Traceback follows...

    Error: file not found
    +Traceback follows...
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes_snapshot.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes_snapshot.html new file mode 100644 index 0000000..b3c21de --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes_snapshot.html @@ -0,0 +1,3 @@ +
    Result

    Tests passed: ✓ All 5 tests passed +Error: None

    Tests passed: ✓ All 5 tests passed
    +Error: None
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html index d5e9dfb..5ac7262 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html @@ -1 +1,2 @@ -
    2 files changed, 10 insertions(+)
    \ No newline at end of file +
    Result
    2 files changed, 10 insertions(+)
    [main abc1234] Add new feature
    + 2 files changed, 10 insertions(+)
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html index 3e3045d..0e17932 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html @@ -1,4 +1,18 @@ +
    -
    $ Bash
    -
    Run tests with verbose output
    pytest tests/ -v
    +
    Call$ Bash +
    + + +
    +
    +
    +

    Run tests with verbose output

    pytest tests/ -v
    +
    +
    +
    {
    +  "command": "pytest tests/ -v",
    +  "description": "Run tests with verbose output"
    +}
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html index 7eef19b..3682674 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html @@ -1,8 +1,24 @@
    -
    ✏️ Edit file.py
    +
    ✏️ Edit file.py +
    + + +
    +
    +
    /project/file.py
    -
    old code here
    -
    +
    new code here
    +
    old code here
    +
    +
    +
    new code here
    +
    +
    +
    +
    {
    +  "file_path": "/project/file.py",
    +  "old_string": "old code here",
    +  "new_string": "new code here"
    +}
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html index ad332b0..24a3a61 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html @@ -1,8 +1,25 @@
    -
    ✏️ Edit file.py (replace all)
    +
    ✏️ Edit file.py (replace all) +
    + + +
    +
    +
    /project/file.py
    -
    old
    -
    +
    new
    +
    old
    +
    +
    +
    new
    +
    +
    +
    +
    {
    +  "file_path": "/project/file.py",
    +  "old_string": "old",
    +  "new_string": "new",
    +  "replace_all": true
    +}
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html index e4396ad..755469b 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html @@ -1 +1,33 @@ -
    Task List
    \ No newline at end of file + +
    +
    Task List +
    + + +
    +
    +
    + +
    +
    +
    {
    +  "todos": [
    +    {
    +      "content": "First task",
    +      "status": "completed",
    +      "activeForm": "First"
    +    },
    +    {
    +      "content": "Second task",
    +      "status": "in_progress",
    +      "activeForm": "Second"
    +    },
    +    {
    +      "content": "Third task",
    +      "status": "pending",
    +      "activeForm": "Third"
    +    }
    +  ]
    +}
    +
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html index 785e8f3..def0610 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html @@ -1,7 +1,20 @@
    -
    📝 Write main.py
    +
    📝 Write main.py +
    + + +
    +
    +
    /project/src/main.py
    -
    def hello():
    -    print('hello world')
    +
    def hello():
    +    print('hello world')
     
    +
    +
    +
    {
    +  "file_path": "/project/src/main.py",
    +  "content": "def hello():\n    print('hello world')\n"
    +}
    +
    \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d0c0a01 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Pytest configuration and fixtures for claude-code-transcripts tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_webbrowser_open(monkeypatch): + """Automatically mock webbrowser.open to prevent browsers opening during tests.""" + opened_urls = [] + + def mock_open(url): + opened_urls.append(url) + return True + + monkeypatch.setattr("claude_code_transcripts.webbrowser.open", mock_open) + return opened_urls diff --git a/tests/sample_session.jsonl b/tests/sample_session.jsonl new file mode 100644 index 0000000..8d4070e --- /dev/null +++ b/tests/sample_session.jsonl @@ -0,0 +1,8 @@ +{"type":"summary","summary":"Test session for JSONL parsing","leafUuid":"test-leaf-uuid"} +{"type":"user","timestamp":"2025-12-24T10:00:00.000Z","sessionId":"test-session-id","cwd":"/project","gitBranch":"main","message":{"role":"user","content":"Create a hello world function"},"uuid":"msg-001"} +{"type":"assistant","timestamp":"2025-12-24T10:00:05.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"text","text":"I'll create that function for you."},{"type":"tool_use","id":"toolu_001","name":"Write","input":{"file_path":"/project/hello.py","content":"def hello():\n return 'Hello, World!'\n"}}]},"uuid":"msg-002"} +{"type":"user","timestamp":"2025-12-24T10:00:10.000Z","sessionId":"test-session-id","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_001","content":"File written successfully"}]},"uuid":"msg-003"} +{"type":"assistant","timestamp":"2025-12-24T10:00:15.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_002","name":"Bash","input":{"command":"git add . && git commit -m 'Add hello function'","description":"Commit changes"}}]},"uuid":"msg-004"} +{"type":"user","timestamp":"2025-12-24T10:00:20.000Z","sessionId":"test-session-id","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_002","content":"[main abc1234] Add hello function\n 1 file changed"}]},"uuid":"msg-005"} +{"type":"user","timestamp":"2025-12-24T10:01:00.000Z","sessionId":"test-session-id","message":{"role":"user","content":"Now add a goodbye function"},"uuid":"msg-006"} +{"type":"assistant","timestamp":"2025-12-24T10:01:05.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"text","text":"Done! The hello function is ready."}]},"uuid":"msg-007"} diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..7e4e601 --- /dev/null +++ b/tests/test_all.py @@ -0,0 +1,532 @@ +"""Tests for batch conversion functionality.""" + +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from claude_code_transcripts import ( + cli, + find_all_sessions, + get_project_display_name, + generate_batch_html, +) + + +@pytest.fixture +def mock_projects_dir(): + """Create a mock ~/.claude/projects structure with test sessions.""" + with tempfile.TemporaryDirectory() as tmpdir: + projects_dir = Path(tmpdir) + + # Create project-a with 2 sessions + project_a = projects_dir / "-home-user-projects-project-a" + project_a.mkdir(parents=True) + + session_a1 = project_a / "abc123.jsonl" + session_a1.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello from project A"}}\n' + '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n' + ) + + session_a2 = project_a / "def456.jsonl" + session_a2.write_text( + '{"type": "user", "timestamp": "2025-01-02T10:00:00.000Z", "message": {"role": "user", "content": "Second session in project A"}}\n' + '{"type": "assistant", "timestamp": "2025-01-02T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Got it!"}]}}\n' + ) + + # Create an agent file (should be skipped by default) + agent_a = project_a / "agent-xyz789.jsonl" + agent_a.write_text( + '{"type": "user", "timestamp": "2025-01-03T10:00:00.000Z", "message": {"role": "user", "content": "Agent session"}}\n' + ) + + # Create project-b with 1 session + project_b = projects_dir / "-home-user-projects-project-b" + project_b.mkdir(parents=True) + + session_b1 = project_b / "ghi789.jsonl" + session_b1.write_text( + '{"type": "user", "timestamp": "2025-01-04T10:00:00.000Z", "message": {"role": "user", "content": "Hello from project B"}}\n' + '{"type": "assistant", "timestamp": "2025-01-04T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Welcome!"}]}}\n' + ) + + # Create empty/warmup session (should be skipped) + warmup = project_b / "warmup123.jsonl" + warmup.write_text( + '{"type": "user", "timestamp": "2025-01-05T10:00:00.000Z", "message": {"role": "user", "content": "warmup"}}\n' + ) + + yield projects_dir + + +@pytest.fixture +def output_dir(): + """Create a temporary output directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +class TestGetProjectDisplayName: + """Tests for get_project_display_name function.""" + + def test_extracts_project_name_from_path(self): + """Test extracting readable project name from encoded path.""" + assert get_project_display_name("-home-user-projects-myproject") == "myproject" + + def test_handles_nested_paths(self): + """Test handling nested project paths.""" + assert get_project_display_name("-home-user-code-apps-webapp") == "apps-webapp" + + def test_handles_windows_style_paths(self): + """Test handling Windows-style encoded paths.""" + assert get_project_display_name("-mnt-c-Users-name-Projects-app") == "app" + + def test_handles_simple_name(self): + """Test handling already simple names.""" + assert get_project_display_name("simple-project") == "simple-project" + + +class TestFindAllSessions: + """Tests for find_all_sessions function.""" + + def test_finds_sessions_grouped_by_project(self, mock_projects_dir): + """Test that sessions are found and grouped by project.""" + result = find_all_sessions(mock_projects_dir) + + # Should have 2 projects + assert len(result) == 2 + + # Check project names are extracted + project_names = [p["name"] for p in result] + assert "project-a" in project_names + assert "project-b" in project_names + + def test_excludes_agent_files_by_default(self, mock_projects_dir): + """Test that agent-* files are excluded by default.""" + result = find_all_sessions(mock_projects_dir) + + # Find project-a + project_a = next(p for p in result if p["name"] == "project-a") + + # Should have 2 sessions (not 3, agent excluded) + assert len(project_a["sessions"]) == 2 + + # No session should be an agent file + for session in project_a["sessions"]: + assert not session["path"].name.startswith("agent-") + + def test_includes_agent_files_when_requested(self, mock_projects_dir): + """Test that agent-* files can be included.""" + result = find_all_sessions(mock_projects_dir, include_agents=True) + + # Find project-a + project_a = next(p for p in result if p["name"] == "project-a") + + # Should have 3 sessions (including agent) + assert len(project_a["sessions"]) == 3 + + def test_excludes_warmup_sessions(self, mock_projects_dir): + """Test that warmup sessions are excluded.""" + result = find_all_sessions(mock_projects_dir) + + # Find project-b + project_b = next(p for p in result if p["name"] == "project-b") + + # Should have 1 session (warmup excluded) + assert len(project_b["sessions"]) == 1 + + def test_sessions_sorted_by_date(self, mock_projects_dir): + """Test that sessions within a project are sorted by modification time.""" + result = find_all_sessions(mock_projects_dir) + + for project in result: + sessions = project["sessions"] + if len(sessions) > 1: + # Check descending order (most recent first) + for i in range(len(sessions) - 1): + assert sessions[i]["mtime"] >= sessions[i + 1]["mtime"] + + def test_returns_empty_for_nonexistent_folder(self): + """Test handling of non-existent folder.""" + result = find_all_sessions(Path("/nonexistent/path")) + assert result == [] + + def test_session_includes_summary(self, mock_projects_dir): + """Test that sessions include summary text.""" + result = find_all_sessions(mock_projects_dir) + + project_a = next(p for p in result if p["name"] == "project-a") + + for session in project_a["sessions"]: + assert "summary" in session + assert session["summary"] != "(no summary)" + + +class TestGenerateBatchHtml: + """Tests for generate_batch_html function.""" + + def test_creates_output_directory(self, mock_projects_dir, output_dir): + """Test that output directory is created.""" + generate_batch_html(mock_projects_dir, output_dir) + assert output_dir.exists() + + def test_creates_master_index(self, mock_projects_dir, output_dir): + """Test that master index.html is created.""" + generate_batch_html(mock_projects_dir, output_dir) + assert (output_dir / "index.html").exists() + + def test_creates_project_directories(self, mock_projects_dir, output_dir): + """Test that project directories are created.""" + generate_batch_html(mock_projects_dir, output_dir) + + assert (output_dir / "project-a").exists() + assert (output_dir / "project-b").exists() + + def test_creates_project_indexes(self, mock_projects_dir, output_dir): + """Test that project index.html files are created.""" + generate_batch_html(mock_projects_dir, output_dir) + + assert (output_dir / "project-a" / "index.html").exists() + assert (output_dir / "project-b" / "index.html").exists() + + def test_creates_session_directories(self, mock_projects_dir, output_dir): + """Test that session directories are created with transcripts.""" + generate_batch_html(mock_projects_dir, output_dir) + + # Check project-a has session directories + project_a_dir = output_dir / "project-a" + session_dirs = [d for d in project_a_dir.iterdir() if d.is_dir()] + assert len(session_dirs) == 2 + + # Each session directory should have an index.html + for session_dir in session_dirs: + assert (session_dir / "index.html").exists() + + def test_master_index_lists_all_projects(self, mock_projects_dir, output_dir): + """Test that master index lists all projects.""" + generate_batch_html(mock_projects_dir, output_dir) + + index_html = (output_dir / "index.html").read_text() + assert "project-a" in index_html + assert "project-b" in index_html + + def test_master_index_shows_session_counts(self, mock_projects_dir, output_dir): + """Test that master index shows session counts per project.""" + generate_batch_html(mock_projects_dir, output_dir) + + index_html = (output_dir / "index.html").read_text() + # project-a has 2 sessions, project-b has 1 + assert "2 sessions" in index_html or "2 session" in index_html + assert "1 session" in index_html + + def test_project_index_lists_sessions(self, mock_projects_dir, output_dir): + """Test that project index lists all sessions.""" + generate_batch_html(mock_projects_dir, output_dir) + + project_a_index = (output_dir / "project-a" / "index.html").read_text() + # Should contain links to session directories + assert "abc123" in project_a_index + assert "def456" in project_a_index + + def test_returns_statistics(self, mock_projects_dir, output_dir): + """Test that batch generation returns statistics.""" + stats = generate_batch_html(mock_projects_dir, output_dir) + + assert stats["total_projects"] == 2 + assert stats["total_sessions"] == 3 # 2 + 1 + assert stats["failed_sessions"] == [] + assert "output_dir" in stats + + def test_progress_callback_called(self, mock_projects_dir, output_dir): + """Test that progress callback is called for each session.""" + progress_calls = [] + + def on_progress(project_name, session_name, current, total): + progress_calls.append((project_name, session_name, current, total)) + + generate_batch_html( + mock_projects_dir, output_dir, progress_callback=on_progress + ) + + # Should be called for each session (3 total) + assert len(progress_calls) == 3 + # Last call should have current == total + assert progress_calls[-1][2] == progress_calls[-1][3] + + def test_handles_failed_session_gracefully(self, output_dir): + """Test that failed session conversion doesn't crash the batch.""" + from unittest.mock import patch + + with tempfile.TemporaryDirectory() as tmpdir: + projects_dir = Path(tmpdir) + + # Create a project with 2 sessions + project = projects_dir / "-home-user-projects-test" + project.mkdir(parents=True) + + # Session 1 + session1 = project / "session1.jsonl" + session1.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello from session 1"}}\n' + ) + + # Session 2 + session2 = project / "session2.jsonl" + session2.write_text( + '{"type": "user", "timestamp": "2025-01-02T10:00:00.000Z", "message": {"role": "user", "content": "Hello from session 2"}}\n' + ) + + # Patch generate_html to fail on one specific session + original_generate_html = __import__("claude_code_transcripts").generate_html + + def mock_generate_html(json_path, output_dir, github_repo=None): + if "session1" in str(json_path): + raise RuntimeError("Simulated failure") + return original_generate_html(json_path, output_dir, github_repo) + + with patch( + "claude_code_transcripts.generate_html", side_effect=mock_generate_html + ): + stats = generate_batch_html(projects_dir, output_dir) + + # Should have processed session2 successfully + assert stats["total_sessions"] == 1 + # Should have recorded session1 as failed + assert len(stats["failed_sessions"]) == 1 + assert "session1" in stats["failed_sessions"][0]["session"] + assert "Simulated failure" in stats["failed_sessions"][0]["error"] + + +class TestAllCommand: + """Tests for the all CLI command.""" + + def test_all_command_exists(self): + """Test that all command is registered.""" + runner = CliRunner() + result = runner.invoke(cli, ["all", "--help"]) + assert result.exit_code == 0 + assert "all" in result.output.lower() or "convert" in result.output.lower() + + def test_all_dry_run(self, mock_projects_dir, output_dir): + """Test dry-run mode shows what would be converted.""" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "all", + "--source", + str(mock_projects_dir), + "--output", + str(output_dir), + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert "project-a" in result.output + assert "project-b" in result.output + # Dry run should not create files + assert not (output_dir / "index.html").exists() + + def test_all_creates_archive(self, mock_projects_dir, output_dir): + """Test all command creates full archive.""" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "all", + "--source", + str(mock_projects_dir), + "--output", + str(output_dir), + ], + ) + + assert result.exit_code == 0 + assert (output_dir / "index.html").exists() + + def test_all_include_agents_flag(self, mock_projects_dir, output_dir): + """Test --include-agents flag includes agent sessions.""" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "all", + "--source", + str(mock_projects_dir), + "--output", + str(output_dir), + "--include-agents", + ], + ) + + assert result.exit_code == 0 + # Should have agent directory in project-a + project_a_dir = output_dir / "project-a" + session_dirs = [d for d in project_a_dir.iterdir() if d.is_dir()] + assert len(session_dirs) == 3 # 2 regular + 1 agent + + def test_all_quiet_flag(self, mock_projects_dir, output_dir): + """Test --quiet flag suppresses non-error output.""" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "all", + "--source", + str(mock_projects_dir), + "--output", + str(output_dir), + "--quiet", + ], + ) + + assert result.exit_code == 0 + # Should create the archive + assert (output_dir / "index.html").exists() + # Output should be minimal (no progress messages) + assert "Scanning" not in result.output + assert "Processed" not in result.output + assert "Generating" not in result.output + + def test_all_quiet_with_dry_run(self, mock_projects_dir, output_dir): + """Test --quiet flag works with --dry-run.""" + runner = CliRunner() + result = runner.invoke( + cli, + [ + "all", + "--source", + str(mock_projects_dir), + "--output", + str(output_dir), + "--dry-run", + "--quiet", + ], + ) + + assert result.exit_code == 0 + # Dry run with quiet should produce no output + assert "Dry run" not in result.output + assert "project-a" not in result.output + # Should not create any files + assert not (output_dir / "index.html").exists() + + +class TestJsonCommandWithUrl: + """Tests for the json command with URL support.""" + + def test_json_command_accepts_url(self, output_dir): + """Test that json command can accept a URL starting with http:// or https://.""" + from unittest.mock import patch, MagicMock + + # Sample JSONL content + jsonl_content = ( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello from URL"}}\n' + '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n' + ) + + # Mock the httpx.get response + mock_response = MagicMock() + mock_response.text = jsonl_content + mock_response.raise_for_status = MagicMock() + + runner = CliRunner() + with patch( + "claude_code_transcripts.httpx.get", return_value=mock_response + ) as mock_get: + result = runner.invoke( + cli, + [ + "json", + "https://example.com/session.jsonl", + "-o", + str(output_dir), + ], + ) + + # Check that the URL was fetched + mock_get.assert_called_once() + call_url = mock_get.call_args[0][0] + assert call_url == "https://example.com/session.jsonl" + + # Check that HTML was generated + assert result.exit_code == 0 + assert (output_dir / "index.html").exists() + + def test_json_command_accepts_http_url(self, output_dir): + """Test that json command can accept http:// URLs.""" + from unittest.mock import patch, MagicMock + + jsonl_content = '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + + mock_response = MagicMock() + mock_response.text = jsonl_content + mock_response.raise_for_status = MagicMock() + + runner = CliRunner() + with patch( + "claude_code_transcripts.httpx.get", return_value=mock_response + ) as mock_get: + result = runner.invoke( + cli, + [ + "json", + "http://example.com/session.jsonl", + "-o", + str(output_dir), + ], + ) + + mock_get.assert_called_once() + assert result.exit_code == 0 + + def test_json_command_url_fetch_error(self, output_dir): + """Test that json command handles URL fetch errors gracefully.""" + from unittest.mock import patch + import httpx + + runner = CliRunner() + with patch( + "claude_code_transcripts.httpx.get", + side_effect=httpx.RequestError("Network error"), + ): + result = runner.invoke( + cli, + [ + "json", + "https://example.com/session.jsonl", + "-o", + str(output_dir), + ], + ) + + assert result.exit_code != 0 + assert "error" in result.output.lower() or "Error" in result.output + + def test_json_command_still_works_with_local_file(self, output_dir): + """Test that json command still works with local file paths.""" + # Create a temp JSONL file + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello local"}}\n' + '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi!"}]}}\n' + ) + + html_output = output_dir / "html_output" + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "json", + str(jsonl_file), + "-o", + str(html_output), + ], + ) + + assert result.exit_code == 0 + assert (html_output / "index.html").exists() diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index e3a354c..f8e29d9 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -7,10 +7,11 @@ import pytest from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode -from claude_code_publish import ( +from claude_code_transcripts import ( generate_html, detect_github_repo, render_markdown_text, + render_json_with_markdown, format_json, is_json_like, render_todo_write, @@ -18,14 +19,25 @@ render_edit_tool, render_bash_tool, render_content_block, + render_assistant_message, + group_blocks_by_type, + strip_ansi, analyze_conversation, format_tool_stats, is_tool_result_message, + inject_gist_preview_js, + create_gist, + GIST_PREVIEW_JS, + parse_session_file, + get_session_summary, + find_local_sessions, + calculate_message_metadata, ) class HTMLSnapshotExtension(SingleFileSnapshotExtension): """Snapshot extension that saves HTML files.""" + _write_mode = WriteMode.TEXT file_extension = "html" @@ -59,7 +71,7 @@ def test_generates_index_html(self, output_dir, snapshot_html): fixture_path = Path(__file__).parent / "sample_session.json" generate_html(fixture_path, output_dir, github_repo="example/project") - index_html = (output_dir / "index.html").read_text() + index_html = (output_dir / "index.html").read_text(encoding="utf-8") assert index_html == snapshot_html def test_generates_page_001_html(self, output_dir, snapshot_html): @@ -67,15 +79,23 @@ def test_generates_page_001_html(self, output_dir, snapshot_html): fixture_path = Path(__file__).parent / "sample_session.json" generate_html(fixture_path, output_dir, github_repo="example/project") - page_html = (output_dir / "page-001.html").read_text() + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") assert page_html == snapshot_html + def test_pairs_tool_use_and_result(self, output_dir): + """Test that tool_use blocks are grouped with tool_result blocks.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + assert 'class="tool-pair"' in page_html + def test_generates_page_002_html(self, output_dir, snapshot_html): """Test page-002.html generation (continuation page).""" fixture_path = Path(__file__).parent / "sample_session.json" generate_html(fixture_path, output_dir, github_repo="example/project") - page_html = (output_dir / "page-002.html").read_text() + page_html = (output_dir / "page-002.html").read_text(encoding="utf-8") assert page_html == snapshot_html def test_github_repo_autodetect(self, sample_session): @@ -84,6 +104,32 @@ def test_github_repo_autodetect(self, sample_session): repo = detect_github_repo(loglines) assert repo == "example/project" + def test_handles_array_content_format(self, tmp_path): + """Test that user messages with array content format are recognized. + + Claude Code v2.0.76+ uses array content format like: + {"type": "user", "message": {"content": [{"type": "text", "text": "..."}]}} + instead of the simpler string format: + {"type": "user", "message": {"content": "..."}} + """ + jsonl_file = tmp_path / "session.jsonl" + jsonl_file.write_text( + '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Hello from array format"}]}}\n' + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hi there!"}]}}\n' + ) + + output_dir = tmp_path / "output" + output_dir.mkdir() + + generate_html(jsonl_file, output_dir) + + index_html = (output_dir / "index.html").read_text(encoding="utf-8") + # Should have 1 prompt, not 0 + assert "1 prompts" in index_html or "1 prompt" in index_html + assert "0 prompts" not in index_html + # The page file should exist + assert (output_dir / "page-001.html").exists() + class TestRenderFunctions: """Tests for individual render functions.""" @@ -106,7 +152,7 @@ def test_format_json(self, snapshot_html): def test_is_json_like(self): """Test JSON-like string detection.""" assert is_json_like('{"key": "value"}') - assert is_json_like('[1, 2, 3]') + assert is_json_like("[1, 2, 3]") assert not is_json_like("plain text") assert not is_json_like("") assert not is_json_like(None) @@ -116,7 +162,11 @@ def test_render_todo_write(self, snapshot_html): tool_input = { "todos": [ {"content": "First task", "status": "completed", "activeForm": "First"}, - {"content": "Second task", "status": "in_progress", "activeForm": "Second"}, + { + "content": "Second task", + "status": "in_progress", + "activeForm": "Second", + }, {"content": "Third task", "status": "pending", "activeForm": "Third"}, ] } @@ -132,7 +182,7 @@ def test_render_write_tool(self, snapshot_html): """Test Write tool rendering.""" tool_input = { "file_path": "/project/src/main.py", - "content": "def hello():\n print('hello world')\n" + "content": "def hello():\n print('hello world')\n", } result = render_write_tool(tool_input, "tool-123") assert result == snapshot_html @@ -142,7 +192,7 @@ def test_render_edit_tool(self, snapshot_html): tool_input = { "file_path": "/project/file.py", "old_string": "old code here", - "new_string": "new code here" + "new_string": "new code here", } result = render_edit_tool(tool_input, "tool-123") assert result == snapshot_html @@ -153,7 +203,7 @@ def test_render_edit_tool_replace_all(self, snapshot_html): "file_path": "/project/file.py", "old_string": "old", "new_string": "new", - "replace_all": True + "replace_all": True, } result = render_edit_tool(tool_input, "tool-123") assert result == snapshot_html @@ -162,18 +212,274 @@ def test_render_bash_tool(self, snapshot_html): """Test Bash tool rendering.""" tool_input = { "command": "pytest tests/ -v", - "description": "Run tests with verbose output" + "description": "Run tests with verbose output", } result = render_bash_tool(tool_input, "tool-123") assert result == snapshot_html + def test_render_bash_tool_markdown_description(self): + """Test Bash tool renders description as Markdown.""" + tool_input = { + "command": "echo hello", + "description": "This is **bold** and _italic_ text", + } + result = render_bash_tool(tool_input, "tool-123") + assert "bold" in result + assert "italic" in result + + def test_render_json_with_markdown_simple(self): + """Test JSON rendering with Markdown in string values.""" + obj = {"key": "This is **bold** text"} + result = render_json_with_markdown(obj) + assert "json-key" in result + assert "json-string-value" in result + assert "bold" in result + + def test_render_json_with_markdown_nested(self): + """Test nested JSON rendering with Markdown.""" + obj = { + "outer": {"inner": "Contains `code` markup"}, + "list": ["item with **bold**", "plain item"], + } + result = render_json_with_markdown(obj) + assert "code" in result + assert "bold" in result + + def test_render_json_with_markdown_types(self): + """Test JSON rendering preserves non-string types.""" + obj = { + "string": "text", + "number": 42, + "float": 3.14, + "bool_true": True, + "bool_false": False, + "null": None, + } + result = render_json_with_markdown(obj) + assert "json-number" in result + assert "json-bool" in result + assert "json-null" in result + + +class TestCellStructure: + """Tests for collapsible cell structure in assistant messages.""" + + def test_group_blocks_by_type(self): + """Test that blocks are correctly grouped by type.""" + blocks = [ + {"type": "thinking", "thinking": "planning..."}, + {"type": "text", "text": "Hello!"}, + {"type": "tool_use", "name": "Bash", "input": {}, "id": "tool-1"}, + {"type": "text", "text": "More text"}, + {"type": "thinking", "thinking": "more planning"}, + ] + groups = group_blocks_by_type(blocks) + assert len(groups["thinking"]) == 2 + assert len(groups["text"]) == 2 + assert len(groups["tools"]) == 1 + + def test_cell_structure_in_assistant_message(self): + """Test that assistant messages contain cell structure.""" + message_data = { + "content": [ + {"type": "thinking", "thinking": "Let me think..."}, + {"type": "text", "text": "Here is my response."}, + ] + } + result = render_assistant_message(message_data) + assert "thinking-cell" in result + assert "response-cell" in result + assert '
    ' in result + assert '
    ' in result + + def test_thinking_cell_closed_by_default(self): + """Test that thinking cell is closed by default.""" + message_data = { + "content": [ + {"type": "thinking", "thinking": "Private thoughts"}, + ] + } + result = render_assistant_message(message_data) + assert '
    ' in result + assert "open" not in result.split("thinking-cell")[1].split(">")[0] + + def test_response_cell_open_by_default(self): + """Test that response cell is open by default.""" + message_data = { + "content": [ + {"type": "text", "text": "Hello!"}, + ] + } + result = render_assistant_message(message_data) + assert '
    ' in result + + def test_tools_cell_shows_count(self): + """Test that tools cell shows tool count.""" + message_data = { + "content": [ + {"type": "tool_use", "name": "Bash", "input": {}, "id": "t1"}, + {"type": "tool_use", "name": "Read", "input": {}, "id": "t2"}, + {"type": "tool_use", "name": "Glob", "input": {}, "id": "t3"}, + ] + } + result = render_assistant_message(message_data) + assert "tools-cell" in result + assert "Tool Calls (3)" in result + + def test_cell_has_copy_button(self): + """Test that each cell has a copy button.""" + message_data = { + "content": [ + {"type": "text", "text": "Hello!"}, + ] + } + result = render_assistant_message(message_data) + assert 'class="cell-copy-btn"' in result + assert 'aria-label="Copy Response"' in result + + def test_cell_copy_button_aria_label(self): + """Test that cell copy buttons have appropriate ARIA labels.""" + message_data = { + "content": [ + {"type": "thinking", "thinking": "Planning..."}, + {"type": "text", "text": "Hello!"}, + {"type": "tool_use", "name": "Bash", "input": {}, "id": "t1"}, + ] + } + result = render_assistant_message(message_data) + assert 'aria-label="Copy Thinking"' in result + assert 'aria-label="Copy Response"' in result + assert 'aria-label="Copy Tool Calls"' in result + + +class TestMessageMetadata: + """Tests for message metadata calculation and rendering.""" + + def test_calculate_metadata_string_content(self): + """Test metadata calculation for string content.""" + message_data = {"content": "Hello, world!"} + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 13 + assert metadata["token_estimate"] == 3 # 13 // 4 = 3 + assert metadata["tool_counts"] == {} + + def test_calculate_metadata_text_blocks(self): + """Test metadata calculation for text blocks.""" + message_data = { + "content": [ + {"type": "text", "text": "Hello!"}, # 6 chars + {"type": "text", "text": "World!"}, # 6 chars + ] + } + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 12 + assert metadata["token_estimate"] == 3 # 12 // 4 = 3 + assert metadata["tool_counts"] == {} + + def test_calculate_metadata_thinking_blocks(self): + """Test metadata includes thinking block content.""" + message_data = { + "content": [ + {"type": "thinking", "thinking": "Let me think..."}, # 15 chars + ] + } + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 15 + assert metadata["token_estimate"] == 3 # 15 // 4 = 3 + + def test_calculate_metadata_tool_counts(self): + """Test tool counting in metadata.""" + message_data = { + "content": [ + {"type": "tool_use", "name": "Bash", "input": {}, "id": "t1"}, + {"type": "tool_use", "name": "Bash", "input": {}, "id": "t2"}, + {"type": "tool_use", "name": "Read", "input": {}, "id": "t3"}, + ] + } + metadata = calculate_message_metadata(message_data) + assert metadata["tool_counts"] == {"Bash": 2, "Read": 1} + + def test_calculate_metadata_empty_content(self): + """Test metadata for empty content.""" + message_data = {"content": ""} + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 0 + assert metadata["token_estimate"] == 0 + assert metadata["tool_counts"] == {} + + def test_metadata_in_rendered_message(self, output_dir): + """Test that metadata section appears in rendered messages.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + assert 'class="message-metadata"' in page_html + assert 'class="metadata-content"' in page_html + assert 'class="metadata-label"' in page_html + assert 'class="metadata-value"' in page_html + + def test_metadata_css_present(self, output_dir): + """Test that metadata CSS classes are defined.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + assert ".message-metadata" in page_html + assert ".metadata-item" in page_html + assert ".metadata-label" in page_html + assert ".metadata-value" in page_html + class TestRenderContentBlock: """Tests for render_content_block function.""" + def test_image_block(self, snapshot_html): + """Test image block rendering with base64 data URL.""" + # 200x200 black GIF - minimal valid GIF with black pixels + # Generated with: from PIL import Image; img = Image.new('RGB', (200, 200), (0, 0, 0)); img.save('black.gif') + import base64 + import io + + # Create a minimal 200x200 black GIF using raw bytes + # GIF89a header + logical screen descriptor + global color table + image data + gif_data = ( + b"GIF89a" # Header + b"\xc8\x00\xc8\x00" # Width 200, Height 200 + b"\x80" # Global color table flag (1 color: 2^(0+1)=2 colors) + b"\x00" # Background color index + b"\x00" # Pixel aspect ratio + b"\x00\x00\x00" # Color 0: black + b"\x00\x00\x00" # Color 1: black (padding) + b"," # Image separator + b"\x00\x00\x00\x00" # Left, Top + b"\xc8\x00\xc8\x00" # Width 200, Height 200 + b"\x00" # No local color table + b"\x08" # LZW minimum code size + b"\x02\x04\x01\x00" # Compressed data (minimal) + b";" # GIF trailer + ) + black_gif_base64 = base64.b64encode(gif_data).decode("ascii") + + block = { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/gif", + "data": black_gif_base64, + }, + } + result = render_content_block(block) + # The result should contain an img tag with data URL + assert 'src="data:image/gif;base64,' in result + assert "max-width: 100%" in result + assert result == snapshot_html + def test_thinking_block(self, snapshot_html): """Test thinking block rendering.""" - block = {"type": "thinking", "thinking": "Let me think about this...\n\n1. First consideration\n2. Second point"} + block = { + "type": "thinking", + "thinking": "Let me think about this...\n\n1. First consideration\n2. Second point", + } result = render_content_block(block) assert result == snapshot_html @@ -188,7 +494,7 @@ def test_tool_result_block(self, snapshot_html): block = { "type": "tool_result", "content": "Command completed successfully\nOutput line 1\nOutput line 2", - "is_error": False + "is_error": False, } result = render_content_block(block) assert result == snapshot_html @@ -198,27 +504,130 @@ def test_tool_result_error(self, snapshot_html): block = { "type": "tool_result", "content": "Error: file not found\nTraceback follows...", - "is_error": True + "is_error": True, } result = render_content_block(block) assert result == snapshot_html + def test_tool_result_with_ansi_codes(self): + """Test that ANSI escape codes are stripped from tool results.""" + block = { + "type": "tool_result", + "content": "\x1b[38;2;166;172;186mTests passed:\x1b[0m \x1b[32m✓\x1b[0m All 5 tests passed\n\x1b[1;31mError:\x1b[0m None", + "is_error": False, + } + result = render_content_block(block) + assert "\x1b[" not in result + assert "[38;2;" not in result + assert "[32m" not in result + assert "[0m" not in result + assert "Tests passed:" in result + assert "All 5 tests passed" in result + def test_tool_result_with_commit(self, snapshot_html): """Test tool result with git commit output.""" - # Need to set the global _github_repo for commit link rendering - import claude_code_publish - old_repo = claude_code_publish._github_repo - claude_code_publish._github_repo = "example/repo" + # Need to set the github repo for commit link rendering + # Using the thread-safe set_github_repo function + import claude_code_transcripts + + old_repo = claude_code_transcripts.get_github_repo() + claude_code_transcripts.set_github_repo("example/repo") try: block = { "type": "tool_result", "content": "[main abc1234] Add new feature\n 2 files changed, 10 insertions(+)", - "is_error": False + "is_error": False, } result = render_content_block(block) assert result == snapshot_html finally: - claude_code_publish._github_repo = old_repo + claude_code_transcripts.set_github_repo(old_repo) + + def test_tool_result_with_ansi_codes_snapshot(self, snapshot_html): + """Test ANSI escape code stripping with snapshot comparison. + + This is a snapshot test companion to test_tool_result_with_ansi_codes + that verifies the complete HTML output structure. + """ + block = { + "type": "tool_result", + "content": "\x1b[38;2;166;172;186mTests passed:\x1b[0m \x1b[32m✓\x1b[0m All 5 tests passed\n\x1b[1;31mError:\x1b[0m None", + "is_error": False, + } + result = render_content_block(block) + # ANSI codes should be stripped + assert "\x1b[" not in result + assert "[38;2;" not in result + assert "[32m" not in result + assert "[0m" not in result + # Content should still be present + assert "Tests passed:" in result + assert "All 5 tests passed" in result + assert result == snapshot_html + + def test_tool_result_content_block_array(self, snapshot_html): + """Test that tool_result with content-block array is rendered properly.""" + block = { + "type": "tool_result", + "content": '[{"type": "text", "text": "Here is the file content:\\n\\nLine 1\\nLine 2"}]', + "is_error": False, + } + result = render_content_block(block) + # Should render as text, not raw JSON + assert "Here is the file content" in result + assert "Line 1" in result + # Should not show raw JSON structure + assert '"type": "text"' not in result + assert result == snapshot_html + + def test_tool_result_content_block_array_with_image(self, snapshot_html): + """Test that image blocks inside tool_result arrays render correctly.""" + block = { + "type": "tool_result", + "content": ( + '[{"type": "image", "source": {"type": "base64",' + ' "media_type": "image/gif", "data": "R0lGODlhAQABAIAAAAUEBA=="}}]' + ), + "is_error": False, + } + result = render_content_block(block) + assert 'src="data:image/gif;base64,' in result + assert "image-block" in result + assert '"type": "image"' not in result + assert result == snapshot_html + + def test_tool_result_content_block_array_with_tool_use(self, snapshot_html): + """Test that tool_use blocks inside tool_result arrays render correctly.""" + block = { + "type": "tool_result", + "content": ( + '[{"type": "tool_use", "id": "toolu_123", "name": "Bash",' + ' "input": {"command": "ls -la", "description": "List files"}}]' + ), + "is_error": False, + } + result = render_content_block(block) + assert "tool-use" in result + assert "bash-tool" in result + assert "List files" in result + assert '"type": "tool_use"' not in result + assert result == snapshot_html + + +class TestStripAnsi: + """Tests for ANSI escape stripping.""" + + def test_strips_csi_sequences(self): + text = "start\x1b[?25hend\x1b[2Jdone" + assert strip_ansi(text) == "startenddone" + + def test_strips_osc_sequences(self): + text = "title\x1b]0;My Title\x07end" + assert strip_ansi(text) == "titleend" + + def test_strips_osc_st_terminator(self): + text = "name\x1b]0;Title\x1b\\end" + assert strip_ansi(text) == "nameend" class TestAnalyzeConversation: @@ -227,13 +636,34 @@ class TestAnalyzeConversation: def test_counts_tools(self): """Test that tool usage is counted.""" messages = [ - ("assistant", json.dumps({ - "content": [ - {"type": "tool_use", "name": "Bash", "id": "1", "input": {}}, - {"type": "tool_use", "name": "Bash", "id": "2", "input": {}}, - {"type": "tool_use", "name": "Write", "id": "3", "input": {}}, - ] - }), "2025-01-01T00:00:00Z"), + ( + "assistant", + json.dumps( + { + "content": [ + { + "type": "tool_use", + "name": "Bash", + "id": "1", + "input": {}, + }, + { + "type": "tool_use", + "name": "Bash", + "id": "2", + "input": {}, + }, + { + "type": "tool_use", + "name": "Write", + "id": "3", + "input": {}, + }, + ] + } + ), + "2025-01-01T00:00:00Z", + ), ] result = analyze_conversation(messages) assert result["tool_counts"]["Bash"] == 2 @@ -242,14 +672,20 @@ def test_counts_tools(self): def test_extracts_commits(self): """Test that git commits are extracted.""" messages = [ - ("user", json.dumps({ - "content": [ + ( + "user", + json.dumps( { - "type": "tool_result", - "content": "[main abc1234] Add new feature\n 1 file changed" + "content": [ + { + "type": "tool_result", + "content": "[main abc1234] Add new feature\n 1 file changed", + } + ] } - ] - }), "2025-01-01T00:00:00Z"), + ), + "2025-01-01T00:00:00Z", + ), ] result = analyze_conversation(messages) assert len(result["commits"]) == 1 @@ -278,11 +714,7 @@ class TestIsToolResultMessage: def test_detects_tool_result_only(self): """Test detection of tool-result-only messages.""" - message = { - "content": [ - {"type": "tool_result", "content": "result"} - ] - } + message = {"content": [{"type": "tool_result", "content": "result"}]} assert is_tool_result_message(message) is True def test_rejects_mixed_content(self): @@ -290,7 +722,7 @@ def test_rejects_mixed_content(self): message = { "content": [ {"type": "text", "text": "hello"}, - {"type": "tool_result", "content": "result"} + {"type": "tool_result", "content": "result"}, ] } assert is_tool_result_message(message) is False @@ -299,3 +731,1215 @@ def test_rejects_empty(self): """Test rejection of empty content.""" assert is_tool_result_message({"content": []}) is False assert is_tool_result_message({"content": "string"}) is False + + +class TestInjectGistPreviewJs: + """Tests for the inject_gist_preview_js function.""" + + def test_injects_js_into_html_files(self, output_dir): + """Test that JS is injected before tag.""" + # Create test HTML files + (output_dir / "index.html").write_text( + "

    Test

    ", encoding="utf-8" + ) + (output_dir / "page-001.html").write_text( + "

    Page 1

    ", encoding="utf-8" + ) + + inject_gist_preview_js(output_dir) + + index_content = (output_dir / "index.html").read_text(encoding="utf-8") + page_content = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # Check JS was injected + assert GIST_PREVIEW_JS in index_content + assert GIST_PREVIEW_JS in page_content + + # Check JS is before + assert index_content.endswith("") + assert "