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 -[](https://pypi.org/project/claude-code-publish/) -[](https://github.com/simonw/claude-code-publish/releases) -[](https://github.com/simonw/claude-code-publish/actions?query=workflow%3ATest) -[](https://github.com/simonw/claude-code-publish/blob/main/LICENSE) +[](https://pypi.org/project/claude-code-transcripts/) +[](https://github.com/simonw/claude-code-transcripts/releases) +[](https://github.com/simonw/claude-code-transcripts/actions?query=workflow%3ATest) +[](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'{content_preview}{html.escape(old_string)}{html.escape(new_string)}{html.escape(command)}{html.escape(str(block))}
" - block_type = block.get("type", "") - if block_type == "thinking": - return 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'')
- else:
- 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'{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'' - - -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 = ['{prompt_num} prompts · {total_messages} messages · {total_tool_calls} tool calls · {total_commits} commits · {total_pages} pages
- {''.join(index_items)} - {index_pagination} -{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}Session continuation summary
{msg_html}
+ + +
+ + +
+