From bef9bfddbdfaea72d8416f3c7a1240543f691e00 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 15:41:24 -0800 Subject: [PATCH 01/62] Apache 2 --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE 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. From cd8fd37045557d8206029bcd685553f803f0740f Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 15:42:31 -0800 Subject: [PATCH 02/62] Update pyproject with license --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f9ba0d8..4389344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,9 @@ name = "claude-code-publish" version = "0.1.0" description = "Add your description here" readme = "README.md" +license = "Apache-2.0" authors = [ - { name = "Simon Willison", email = "swillison@gmail.com" } + { name = "Simon Willison" } ] requires-python = ">=3.10" dependencies = ["markdown"] From a0b8f816eb4ac0d847a5cde3b4269227c7797bb6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 15:42:55 -0800 Subject: [PATCH 03/62] Release 0.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4389344..2a854e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "claude-code-publish" -version = "0.1.0" +version = "0.1" description = "Add your description here" readme = "README.md" license = "Apache-2.0" From 72dd8212288c09af38c30cbf0119a949ae10aa9a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 15:45:24 -0800 Subject: [PATCH 04/62] description and project.urls --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2a854e4..be6a149 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "claude-code-publish" version = "0.1" -description = "Add your description here" +description = "Convert a Claude Code for web session.json to HTML" readme = "README.md" license = "Apache-2.0" authors = [ @@ -10,6 +10,12 @@ authors = [ requires-python = ">=3.10" dependencies = ["markdown"] +[project.urls] +Homepage = "https://github.com/simonw/claude-code-publish" +Changelog = "https://github.com/simonw/claude-code-publish/releases" +Issues = "https://github.com/simonw/claude-code-publish/issues" +CI = "https://github.com/simonw/claude-code-publish/actions" + [project.scripts] claude-code-publish = "claude_code_publish:main" From 651a77a1931c3aeb95e71f047c86356ac5486bc8 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 16:01:48 -0800 Subject: [PATCH 05/62] Move to click and click-default-group for argument parsing --- pyproject.toml | 6 +- src/claude_code_publish/__init__.py | 157 +++++++++++++++++----------- tests/test_generate_html.py | 90 +++++++++++----- 3 files changed, 164 insertions(+), 89 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index be6a149..c8b8d30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,11 @@ authors = [ { name = "Simon Willison" } ] requires-python = ">=3.10" -dependencies = ["markdown"] +dependencies = [ + "click", + "click-default-group", + "markdown", +] [project.urls] Homepage = "https://github.com/simonw/claude-code-publish" diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index d744510..56eda2a 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -1,21 +1,26 @@ """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 click +from click_default_group import DefaultGroup import markdown # Regex to match git commit output: [branch hash] message -COMMIT_PATTERN = re.compile(r'\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)') +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/') +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 +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 @@ -54,7 +59,7 @@ def format_json(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))}
' + return f"
{html.escape(str(obj))}
" def render_markdown_text(text): @@ -67,7 +72,9 @@ 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("]")) + return (text.startswith("{") and text.endswith("}")) or ( + text.startswith("[") and text.endswith("]") + ) def render_todo_write(tool_input, tool_id): @@ -84,7 +91,9 @@ def render_todo_write(tool_input, tool_id): icon, status_class = "โ†’", "todo-in-progress" else: icon, status_class = "โ—‹", "todo-pending" - items_html.append(f'
  • {icon}{html.escape(content)}
  • ') + items_html.append( + f'
  • {icon}{html.escape(content)}
  • ' + ) return f'
    โ˜ฐ Task List
      {"".join(items_html)}
    ' @@ -95,11 +104,11 @@ def render_write_tool(tool_input, tool_id): # Extract filename from path filename = file_path.split("/")[-1] if "/" in file_path else file_path content_preview = html.escape(content) - return f'''
    + return f"""
    ๐Ÿ“ Write {html.escape(filename)}
    {html.escape(file_path)}
    {content_preview}
    -
    ''' +
    """ def render_edit_tool(tool_input, tool_id): @@ -110,26 +119,32 @@ def render_edit_tool(tool_input, tool_id): replace_all = tool_input.get("replace_all", False) # Extract filename from path filename = file_path.split("/")[-1] if "/" in file_path else file_path - replace_note = ' (replace all)' if replace_all else "" - return f'''
    + replace_note = ( + ' (replace all)' if replace_all else "" + ) + return f"""
    โœ๏ธ Edit {html.escape(filename)}{replace_note}
    {html.escape(file_path)}
    โˆ’
    {html.escape(old_string)}
    +
    {html.escape(new_string)}
    -
    ''' +
    """ def render_bash_tool(tool_input, tool_id): """Render Bash tool calls with command as plain text.""" command = tool_input.get("command", "") description = tool_input.get("description", "") - desc_html = f'
    {html.escape(description)}
    ' if description else "" - return f'''
    + desc_html = ( + f'
    {html.escape(description)}
    ' + if description + else "" + ) + return f"""
    $ Bash
    {desc_html}
    {html.escape(command)}
    -
    ''' +
    """ def render_content_block(block): @@ -153,7 +168,11 @@ def render_content_block(block): if tool_name == "Bash": return render_bash_tool(tool_input, tool_id) description = tool_input.get("description", "") - desc_html = f'
    {html.escape(description)}
    ' if description else "" + desc_html = ( + f'
    {html.escape(description)}
    ' + if description + else "" + ) display_input = {k: v for k, v in tool_input.items() if k != "description"} return f'
    โš™ {html.escape(tool_name)}
    {desc_html}
    {format_json(display_input)}
    ' elif block_type == "tool_result": @@ -170,25 +189,31 @@ def render_content_block(block): last_end = 0 for match in commits_found: # Add any content before this commit - before = content[last_end:match.start()].strip() + before = content[last_end : match.start()].strip() if before: - parts.append(f'
    {html.escape(before)}
    ') + parts.append(f"
    {html.escape(before)}
    ") commit_hash = match.group(1) commit_msg = match.group(2) if _github_repo: - github_link = f'https://github.com/{_github_repo}/commit/{commit_hash}' - parts.append(f'') + github_link = ( + f"https://github.com/{_github_repo}/commit/{commit_hash}" + ) + parts.append( + f'' + ) else: - parts.append(f'
    {commit_hash[:7]} {html.escape(commit_msg)}
    ') + parts.append( + f'
    {commit_hash[:7]} {html.escape(commit_msg)}
    ' + ) last_end = match.end() # Add any remaining content after last commit after = content[last_end:].strip() if after: - parts.append(f'
    {html.escape(after)}
    ') + parts.append(f"
    {html.escape(after)}
    ") - content_html = ''.join(parts) + content_html = "".join(parts) else: content_html = f"
    {html.escape(content)}
    " elif isinstance(content, list) or is_json_like(content): @@ -331,7 +356,7 @@ def render_message(log_type, message_json, timestamp): return f'
    {content_html}
    ' -CSS = ''' +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; } @@ -448,9 +473,9 @@ def render_message(log_type, message_json, timestamp): .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 = ''' +JS = """ document.querySelectorAll('time[data-timestamp]').forEach(function(el) { const timestamp = el.getAttribute('data-timestamp'); const date = new Date(timestamp); @@ -479,13 +504,16 @@ def render_message(log_type, message_json, timestamp): }); } }); -''' +""" def generate_pagination_html(current_page, total_pages): if total_pages <= 1: return '' - parts = ['") + return "\n".join(parts) def generate_index_pagination_html(total_pages): @@ -516,8 +544,8 @@ def generate_index_pagination_html(total_pages): parts.append('Next โ†’') else: parts.append('Next โ†’') - parts.append('') - return '\n'.join(parts) + parts.append("") + return "\n".join(parts) def generate_html(json_path, output_dir, github_repo=None): @@ -536,7 +564,9 @@ def generate_html(json_path, output_dir, github_repo=None): 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.") + print( + "Warning: Could not auto-detect GitHub repo. Commit links will be disabled." + ) # Set module-level variable for render functions global _github_repo @@ -593,7 +623,7 @@ def generate_html(json_path, output_dir, github_repo=None): messages_html.append(msg_html) is_first = False pagination_html = generate_pagination_html(page_num, total_pages) - page_content = f''' + page_content = f""" @@ -610,7 +640,7 @@ def generate_html(json_path, output_dir, github_repo=None): -''' +""" (output_dir / f"page-{page_num:03d}.html").write_text(page_content) print(f"Generated page-{page_num:03d}.html") @@ -656,8 +686,10 @@ def generate_html(json_path, output_dir, github_repo=None): rendered_lt = render_markdown_text(lt) long_texts_html += f'
    {rendered_lt}
    ' - stats_line = f'{tool_stats_str}' if tool_stats_str else "" - stats_html = f'
    {stats_line}{long_texts_html}
    ' + stats_line = f"{tool_stats_str}" if tool_stats_str else "" + stats_html = ( + f'
    {stats_line}{long_texts_html}
    ' + ) item_html = f'' timeline_items.append((conv["timestamp"], "prompt", item_html)) @@ -666,9 +698,9 @@ def generate_html(json_path, output_dir, github_repo=None): for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: if _github_repo: github_link = f"https://github.com/{_github_repo}/commit/{commit_hash}" - item_html = f'''''' + item_html = f"""""" else: - item_html = f'''
    {commit_hash[:7]}
    {html.escape(commit_msg)}
    ''' + item_html = f"""
    {commit_hash[:7]}
    {html.escape(commit_msg)}
    """ timeline_items.append((commit_ts, "commit", item_html)) # Sort by timestamp @@ -676,7 +708,7 @@ def generate_html(json_path, output_dir, github_repo=None): index_items = [item[2] for item in timeline_items] index_pagination = generate_index_pagination_html(total_pages) - index_content = f''' + index_content = f""" @@ -694,27 +726,34 @@ def generate_html(json_path, output_dir, github_repo=None): -''' +""" (output_dir / "index.html").write_text(index_content) print(f"Generated index.html ({total_convs} prompts, {total_pages} pages)") +@click.group(cls=DefaultGroup, default="session", default_if_no_args=False) +def cli(): + """Convert Claude Code session JSON to mobile-friendly HTML pages.""" + pass + + +@cli.command() +@click.argument("json_file", type=click.Path(exists=True)) +@click.option( + "-o", + "--output", + default=".", + type=click.Path(), + help="Output directory (default: current directory)", +) +@click.option( + "--repo", + help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", +) +def session(json_file, output, repo): + """Convert a Claude Code session JSON file to HTML.""" + generate_html(json_file, output, github_repo=repo) + + def main(): - parser = argparse.ArgumentParser( - description="Convert Claude Code session JSON to mobile-friendly HTML pages." - ) - parser.add_argument( - "json_file", - help="Path to the Claude Code session JSON file" - ) - parser.add_argument( - "-o", "--output", - default=".", - help="Output directory (default: current directory)" - ) - parser.add_argument( - "--repo", - help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified." - ) - args = parser.parse_args() - generate_html(args.json_file, args.output, github_repo=args.repo) + cli() diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index e3a354c..1f45670 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -26,6 +26,7 @@ class HTMLSnapshotExtension(SingleFileSnapshotExtension): """Snapshot extension that saves HTML files.""" + _write_mode = WriteMode.TEXT file_extension = "html" @@ -106,7 +107,7 @@ def test_format_json(self, snapshot_html): def test_is_json_like(self): """Test JSON-like string detection.""" assert is_json_like('{"key": "value"}') - assert is_json_like('[1, 2, 3]') + assert is_json_like("[1, 2, 3]") assert not is_json_like("plain text") assert not is_json_like("") assert not is_json_like(None) @@ -116,7 +117,11 @@ def test_render_todo_write(self, snapshot_html): tool_input = { "todos": [ {"content": "First task", "status": "completed", "activeForm": "First"}, - {"content": "Second task", "status": "in_progress", "activeForm": "Second"}, + { + "content": "Second task", + "status": "in_progress", + "activeForm": "Second", + }, {"content": "Third task", "status": "pending", "activeForm": "Third"}, ] } @@ -132,7 +137,7 @@ def test_render_write_tool(self, snapshot_html): """Test Write tool rendering.""" tool_input = { "file_path": "/project/src/main.py", - "content": "def hello():\n print('hello world')\n" + "content": "def hello():\n print('hello world')\n", } result = render_write_tool(tool_input, "tool-123") assert result == snapshot_html @@ -142,7 +147,7 @@ def test_render_edit_tool(self, snapshot_html): tool_input = { "file_path": "/project/file.py", "old_string": "old code here", - "new_string": "new code here" + "new_string": "new code here", } result = render_edit_tool(tool_input, "tool-123") assert result == snapshot_html @@ -153,7 +158,7 @@ def test_render_edit_tool_replace_all(self, snapshot_html): "file_path": "/project/file.py", "old_string": "old", "new_string": "new", - "replace_all": True + "replace_all": True, } result = render_edit_tool(tool_input, "tool-123") assert result == snapshot_html @@ -162,7 +167,7 @@ def test_render_bash_tool(self, snapshot_html): """Test Bash tool rendering.""" tool_input = { "command": "pytest tests/ -v", - "description": "Run tests with verbose output" + "description": "Run tests with verbose output", } result = render_bash_tool(tool_input, "tool-123") assert result == snapshot_html @@ -173,7 +178,10 @@ class TestRenderContentBlock: def test_thinking_block(self, snapshot_html): """Test thinking block rendering.""" - block = {"type": "thinking", "thinking": "Let me think about this...\n\n1. First consideration\n2. Second point"} + block = { + "type": "thinking", + "thinking": "Let me think about this...\n\n1. First consideration\n2. Second point", + } result = render_content_block(block) assert result == snapshot_html @@ -188,7 +196,7 @@ def test_tool_result_block(self, snapshot_html): block = { "type": "tool_result", "content": "Command completed successfully\nOutput line 1\nOutput line 2", - "is_error": False + "is_error": False, } result = render_content_block(block) assert result == snapshot_html @@ -198,7 +206,7 @@ def test_tool_result_error(self, snapshot_html): block = { "type": "tool_result", "content": "Error: file not found\nTraceback follows...", - "is_error": True + "is_error": True, } result = render_content_block(block) assert result == snapshot_html @@ -207,13 +215,14 @@ def test_tool_result_with_commit(self, snapshot_html): """Test tool result with git commit output.""" # Need to set the global _github_repo for commit link rendering import claude_code_publish + old_repo = claude_code_publish._github_repo claude_code_publish._github_repo = "example/repo" try: block = { "type": "tool_result", "content": "[main abc1234] Add new feature\n 2 files changed, 10 insertions(+)", - "is_error": False + "is_error": False, } result = render_content_block(block) assert result == snapshot_html @@ -227,13 +236,34 @@ class TestAnalyzeConversation: def test_counts_tools(self): """Test that tool usage is counted.""" messages = [ - ("assistant", json.dumps({ - "content": [ - {"type": "tool_use", "name": "Bash", "id": "1", "input": {}}, - {"type": "tool_use", "name": "Bash", "id": "2", "input": {}}, - {"type": "tool_use", "name": "Write", "id": "3", "input": {}}, - ] - }), "2025-01-01T00:00:00Z"), + ( + "assistant", + json.dumps( + { + "content": [ + { + "type": "tool_use", + "name": "Bash", + "id": "1", + "input": {}, + }, + { + "type": "tool_use", + "name": "Bash", + "id": "2", + "input": {}, + }, + { + "type": "tool_use", + "name": "Write", + "id": "3", + "input": {}, + }, + ] + } + ), + "2025-01-01T00:00:00Z", + ), ] result = analyze_conversation(messages) assert result["tool_counts"]["Bash"] == 2 @@ -242,14 +272,20 @@ def test_counts_tools(self): def test_extracts_commits(self): """Test that git commits are extracted.""" messages = [ - ("user", json.dumps({ - "content": [ + ( + "user", + json.dumps( { - "type": "tool_result", - "content": "[main abc1234] Add new feature\n 1 file changed" + "content": [ + { + "type": "tool_result", + "content": "[main abc1234] Add new feature\n 1 file changed", + } + ] } - ] - }), "2025-01-01T00:00:00Z"), + ), + "2025-01-01T00:00:00Z", + ), ] result = analyze_conversation(messages) assert len(result["commits"]) == 1 @@ -278,11 +314,7 @@ class TestIsToolResultMessage: def test_detects_tool_result_only(self): """Test detection of tool-result-only messages.""" - message = { - "content": [ - {"type": "tool_result", "content": "result"} - ] - } + message = {"content": [{"type": "tool_result", "content": "result"}]} assert is_tool_result_message(message) is True def test_rejects_mixed_content(self): @@ -290,7 +322,7 @@ def test_rejects_mixed_content(self): message = { "content": [ {"type": "text", "text": "hello"}, - {"type": "tool_result", "content": "result"} + {"type": "tool_result", "content": "result"}, ] } assert is_tool_result_message(message) is False From e1fd054a79fc861991a139e635e0da0a17064832 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 17:38:51 -0800 Subject: [PATCH 06/62] list-web and import commands using unofficial Claude API I reverse-engineered the API running Codex against the claude JS code like this: https://gistpreview.github.io/?e4159193cd2468060d91289b5ccdece3 Then had Claude Code build the features: https://gistpreview.github.io/?3e2a728845c250cf9b1ad5c0ee645d32 --- pyproject.toml | 3 + src/claude_code_publish/__init__.py | 443 ++++++++++++++++++++++++++++ tests/test_generate_html.py | 92 ++++++ 3 files changed, 538 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index c8b8d30..745296b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,9 @@ requires-python = ">=3.10" dependencies = [ "click", "click-default-group", + "httpx", "markdown", + "questionary", ] [project.urls] @@ -30,5 +32,6 @@ build-backend = "uv_build" [dependency-groups] dev = [ "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 index 56eda2a..78a9542 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -2,12 +2,17 @@ import json import html +import os +import platform import re +import subprocess from pathlib import Path import click from click_default_group import DefaultGroup +import httpx import markdown +import questionary # Regex to match git commit output: [branch hash] message COMMIT_PATTERN = re.compile(r"\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)") @@ -25,6 +30,104 @@ # Module-level variable for GitHub repo (set by generate_html) _github_repo = None +# API constants +API_BASE_URL = "https://api.anthropic.com/v1" +ANTHROPIC_VERSION = "2023-06-01" + + +class CredentialsError(Exception): + """Raised when credentials cannot be obtained.""" + + pass + + +def get_access_token_from_keychain(): + """Get access token from macOS keychain. + + Returns the access token or None if not found. + Raises CredentialsError with helpful message on failure. + """ + if platform.system() != "Darwin": + return None + + try: + result = subprocess.run( + [ + "security", + "find-generic-password", + "-a", + os.environ.get("USER", ""), + "-s", + "Claude Code-credentials", + "-w", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + + # Parse the JSON to get the access token + creds = json.loads(result.stdout.strip()) + return creds.get("claudeAiOauth", {}).get("accessToken") + except (json.JSONDecodeError, subprocess.SubprocessError): + return None + + +def get_org_uuid_from_config(): + """Get organization UUID from ~/.claude.json. + + Returns the organization UUID or None if not found. + """ + config_path = Path.home() / ".claude.json" + if not config_path.exists(): + return None + + try: + with open(config_path) as f: + config = json.load(f) + return config.get("oauthAccount", {}).get("organizationUuid") + except (json.JSONDecodeError, IOError): + return None + + +def get_api_headers(token, org_uuid): + """Build API request headers.""" + return { + "Authorization": f"Bearer {token}", + "anthropic-version": ANTHROPIC_VERSION, + "Content-Type": "application/json", + "x-organization-uuid": org_uuid, + } + + +def fetch_sessions(token, org_uuid): + """Fetch list of sessions from the API. + + Returns the sessions data as a dict. + Raises httpx.HTTPError on network/API errors. + """ + headers = get_api_headers(token, org_uuid) + response = httpx.get(f"{API_BASE_URL}/sessions", headers=headers, timeout=30.0) + response.raise_for_status() + return response.json() + + +def fetch_session(token, org_uuid, session_id): + """Fetch a specific session from the API. + + Returns the session data as a dict. + Raises httpx.HTTPError on network/API errors. + """ + headers = get_api_headers(token, org_uuid) + response = httpx.get( + f"{API_BASE_URL}/session_ingress/session/{session_id}", + headers=headers, + timeout=60.0, + ) + response.raise_for_status() + return response.json() + def detect_github_repo(loglines): """ @@ -755,5 +858,345 @@ def session(json_file, output, repo): generate_html(json_file, output, github_repo=repo) +def resolve_credentials(token, org_uuid): + """Resolve token and org_uuid from arguments or auto-detect. + + Returns (token, org_uuid) tuple. + Raises click.ClickException if credentials cannot be resolved. + """ + # Get token + if token is None: + token = get_access_token_from_keychain() + if token is None: + if platform.system() == "Darwin": + raise click.ClickException( + "Could not retrieve access token from macOS keychain. " + "Make sure you are logged into Claude Code, or provide --token." + ) + else: + raise click.ClickException( + "On non-macOS platforms, you must provide --token with your access token." + ) + + # Get org UUID + if org_uuid is None: + org_uuid = get_org_uuid_from_config() + if org_uuid is None: + raise click.ClickException( + "Could not find organization UUID in ~/.claude.json. " + "Provide --org-uuid with your organization UUID." + ) + + return token, org_uuid + + +def format_session_for_display(session_data): + """Format a session for display in the list or picker. + + Returns a formatted string. + """ + session_id = session_data.get("id", "unknown") + title = session_data.get("title", "Untitled") + created_at = session_data.get("created_at", "") + # Truncate title if too long + if len(title) > 60: + title = title[:57] + "..." + return f"{session_id} {created_at[:19] if created_at else 'N/A':19} {title}" + + +@cli.command("list-web") +@click.option("--token", help="API access token (auto-detected from keychain on macOS)") +@click.option( + "--org-uuid", help="Organization UUID (auto-detected from ~/.claude.json)" +) +def list_web(token, org_uuid): + """List available sessions from the Claude API.""" + try: + token, org_uuid = resolve_credentials(token, org_uuid) + except click.ClickException: + raise + + try: + sessions_data = fetch_sessions(token, org_uuid) + except httpx.HTTPStatusError as e: + raise click.ClickException( + f"API request failed: {e.response.status_code} {e.response.text}" + ) + except httpx.RequestError as e: + raise click.ClickException(f"Network error: {e}") + + sessions = sessions_data.get("data", []) + if not sessions: + click.echo("No sessions found.") + return + + # Print header + click.echo(f"{'Session ID':<35} {'Created':<19} Name") + click.echo("-" * 80) + + for session_data in sessions: + click.echo(format_session_for_display(session_data)) + + +def generate_html_from_session_data(session_data, output_dir, github_repo=None): + """Generate HTML from session data dict (instead of file path).""" + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + + loglines = session_data.get("loglines", []) + + # Auto-detect GitHub repo if not provided + if github_repo is None: + github_repo = detect_github_repo(loglines) + if github_repo: + click.echo(f"Auto-detected GitHub repo: {github_repo}") + + # Set module-level variable for render functions + global _github_repo + _github_repo = github_repo + + conversations = [] + current_conv = None + for entry in loglines: + log_type = entry.get("type") + timestamp = entry.get("timestamp", "") + is_compact_summary = entry.get("isCompactSummary", False) + message_data = entry.get("message", {}) + if not message_data: + continue + # Convert message dict to JSON string for compatibility with existing render functions + message_json = json.dumps(message_data) + is_user_prompt = False + user_text = None + if log_type == "user": + content = message_data.get("content", "") + if isinstance(content, str) and content.strip(): + is_user_prompt = True + user_text = content + if is_user_prompt: + if current_conv: + conversations.append(current_conv) + current_conv = { + "user_text": user_text, + "timestamp": timestamp, + "messages": [(log_type, message_json, timestamp)], + "is_continuation": bool(is_compact_summary), + } + elif current_conv: + current_conv["messages"].append((log_type, message_json, timestamp)) + if current_conv: + conversations.append(current_conv) + + total_convs = len(conversations) + total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE + + for page_num in range(1, total_pages + 1): + start_idx = (page_num - 1) * PROMPTS_PER_PAGE + end_idx = min(start_idx + PROMPTS_PER_PAGE, total_convs) + page_convs = conversations[start_idx:end_idx] + messages_html = [] + for conv in page_convs: + is_first = True + for log_type, message_json, timestamp in conv["messages"]: + msg_html = render_message(log_type, message_json, timestamp) + if msg_html: + # Wrap continuation summaries in collapsed details + if is_first and conv.get("is_continuation"): + msg_html = f'
    Session continuation summary{msg_html}
    ' + messages_html.append(msg_html) + is_first = False + pagination_html = generate_pagination_html(page_num, total_pages) + page_content = f""" + + + + + Claude Code transcript - page {page_num} + + + +
    +

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

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

    Claude Code transcript

    + {index_pagination} +

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

    + {''.join(index_items)} + {index_pagination} +
    + + +""" + (output_dir / "index.html").write_text(index_content) + click.echo(f"Generated index.html ({total_convs} prompts, {total_pages} pages)") + + +@cli.command("import") +@click.argument("session_id", required=False) +@click.option( + "-o", + "--output", + type=click.Path(), + help="Output directory (default: creates folder with session ID)", +) +@click.option("--token", help="API access token (auto-detected from keychain on macOS)") +@click.option( + "--org-uuid", help="Organization UUID (auto-detected from ~/.claude.json)" +) +@click.option( + "--repo", + help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", +) +def import_session(session_id, output, token, org_uuid, repo): + """Import a session from the Claude API and convert to HTML. + + If SESSION_ID is not provided, displays an interactive picker to select a session. + """ + try: + token, org_uuid = resolve_credentials(token, org_uuid) + except click.ClickException: + raise + + # If no session ID provided, show interactive picker + if session_id is None: + try: + sessions_data = fetch_sessions(token, org_uuid) + except httpx.HTTPStatusError as e: + raise click.ClickException( + f"API request failed: {e.response.status_code} {e.response.text}" + ) + except httpx.RequestError as e: + raise click.ClickException(f"Network error: {e}") + + sessions = sessions_data.get("data", []) + if not sessions: + raise click.ClickException("No sessions found.") + + # Build choices for questionary + choices = [] + for s in sessions: + sid = s.get("id", "unknown") + title = s.get("title", "Untitled") + created_at = s.get("created_at", "") + # Truncate title if too long + if len(title) > 50: + title = title[:47] + "..." + display = f"{created_at[:19] if created_at else 'N/A':19} {title}" + choices.append(questionary.Choice(title=display, value=sid)) + + selected = questionary.select( + "Select a session to import:", + choices=choices, + ).ask() + + if selected is None: + # User cancelled + raise click.ClickException("No session selected.") + + session_id = selected + + # Fetch the session + click.echo(f"Fetching session {session_id}...") + try: + session_data = fetch_session(token, org_uuid, session_id) + except httpx.HTTPStatusError as e: + raise click.ClickException( + f"API request failed: {e.response.status_code} {e.response.text}" + ) + except httpx.RequestError as e: + raise click.ClickException(f"Network error: {e}") + + # Determine output directory + if output is None: + output = session_id + + click.echo(f"Generating HTML in {output}/...") + generate_html_from_session_data(session_data, output, github_repo=repo) + click.echo(f"Done! Open {output}/index.html to view.") + + def main(): cli() diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 1f45670..cc70449 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -331,3 +331,95 @@ def test_rejects_empty(self): """Test rejection of empty content.""" assert is_tool_result_message({"content": []}) is False assert is_tool_result_message({"content": "string"}) is False + + +class TestListWebCommand: + """Tests for the list-web command.""" + + def test_list_web_displays_sessions(self, httpx_mock): + """Test that list-web displays sessions from the API.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Mock the API response with realistic data + mock_response = { + "data": [ + { + "id": "session_01ABC123", + "title": "Build a CLI tool", + "created_at": "2025-12-24T10:30:00Z", + "updated_at": "2025-12-24T11:00:00Z", + "type": "web", + "session_status": "completed", + "environment_id": "env_123", + "session_context": {}, + }, + { + "id": "session_02DEF456", + "title": "Fix authentication bug", + "created_at": "2025-12-23T14:00:00Z", + "updated_at": "2025-12-23T15:30:00Z", + "type": "web", + "session_status": "completed", + "environment_id": "env_123", + "session_context": {}, + }, + ], + "has_more": False, + "first_id": "session_01ABC123", + "last_id": "session_02DEF456", + } + + httpx_mock.add_response( + url="https://api.anthropic.com/v1/sessions", + json=mock_response, + ) + + runner = CliRunner() + result = runner.invoke( + cli, + ["list-web", "--token", "test-token", "--org-uuid", "test-org-uuid"], + ) + + assert result.exit_code == 0 + assert "session_01ABC123" in result.output + assert "session_02DEF456" in result.output + assert "Build a CLI tool" in result.output + assert "Fix authentication bug" in result.output + assert "2025-12-24T10:30:00" in result.output + + def test_list_web_no_sessions(self, httpx_mock): + """Test list-web when no sessions are found.""" + from click.testing import CliRunner + from claude_code_publish import cli + + httpx_mock.add_response( + url="https://api.anthropic.com/v1/sessions", + json={"data": [], "has_more": False}, + ) + + runner = CliRunner() + result = runner.invoke( + cli, + ["list-web", "--token", "test-token", "--org-uuid", "test-org-uuid"], + ) + + assert result.exit_code == 0 + assert "No sessions found" in result.output + + def test_list_web_requires_token_on_non_macos(self, monkeypatch): + """Test that list-web requires --token on non-macOS platforms.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Pretend we're on Linux + monkeypatch.setattr("claude_code_publish.platform.system", lambda: "Linux") + + runner = CliRunner() + result = runner.invoke( + cli, + ["list-web", "--org-uuid", "test-org-uuid"], + ) + + assert result.exit_code != 0 + assert "must provide --token" in result.output From 62fe91f27357c310feaad73f4b25a8d689e50efb Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 18:27:26 -0800 Subject: [PATCH 07/62] New option --gist to publish straight to a Gist via gh CLI Add a --gist feature to both of the commands that can output a session converted to HTML. If --gist is provided without a -o then it uses a tmp directory for the export (still with the session ID as the name) which is still printed out but is expected to be deleted by the OS at some point. The --gist option causes the content of that folder - the index.html and any page_*.html files as multiple files in a single gist. There is just one catch: I intend to serve those files using https://gistpreview.github.io/?3769c0736f45668e9595eb4eb8493f9c/index.html - but any links from that page need to go to https://gistpreview.github.io/?3769c0736f45668e9595eb4eb8493f9c/two.html so relative URLs will not work as they will incorrectly go to https://gistpreview.github.io/two.html. So... if --gist is used then a little bit of javascript needs to be injected into the bottom of the index.html and other pages which checks to see if the browser is accessing the page on gistpreview.github.io and if it is corrects any a href links on the page Do not forget the tests and the README https://gistpreview.github.io/?cbb6a57bc23ba2695bdc4e8d76997789 --- README.md | 46 +++++ src/claude_code_publish/__init__.py | 130 +++++++++++++- tests/test_generate_html.py | 254 ++++++++++++++++++++++++++++ 3 files changed, 423 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9ff7230..60d3b98 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,52 @@ This will generate: - `-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) +- `--gist` - upload the generated HTML files to a GitHub Gist and output a preview URL + +### 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-publish 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. + +When using `--gist` without `-o`, files are written to a temporary directory (shown in the output). You can combine both options to keep a local copy: + +```bash +claude-code-publish 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`). + +## Importing from Claude API + +You can import sessions directly from the Claude API without needing to export a `session.json` file: + +```bash +# List available sessions +claude-code-publish list-web + +# Import a specific session +claude-code-publish import SESSION_ID -o output-directory/ + +# Import with interactive session picker +claude-code-publish import + +# Import and publish to gist +claude-code-publish import SESSION_ID --gist +``` + +On macOS, the API credentials are automatically retrieved from your keychain (requires being logged into Claude Code). On other platforms, provide `--token` and `--org-uuid` manually. ## Development diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 78a9542..6381103 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -6,6 +6,7 @@ import platform import re import subprocess +import tempfile from pathlib import Path import click @@ -609,6 +610,79 @@ def render_message(log_type, message_json, timestamp): }); """ +# 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); + }); +})(); +""" + + +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() + # Insert the gist preview JS before the closing tag + if "" in content: + content = content.replace( + "", + f"\n" + ) + html_file.write_text(content) + + +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): if total_pages <= 1: @@ -845,18 +919,42 @@ def cli(): @click.option( "-o", "--output", - default=".", type=click.Path(), - help="Output directory (default: current directory)", + help="Output directory (default: current directory, or temp dir with --gist)", ) @click.option( "--repo", help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", ) -def session(json_file, output, repo): +@click.option( + "--gist", + is_flag=True, + help="Upload to GitHub Gist and output a gistpreview.github.io URL.", +) +def session(json_file, output, repo, gist): """Convert a Claude Code session JSON file to HTML.""" + # Determine output directory + if gist and output is None: + # Extract session ID from JSON file for temp directory name + with open(json_file, "r") as f: + data = json.load(f) + session_id = data.get("sessionId", Path(json_file).stem) + output = Path(tempfile.gettempdir()) / session_id + elif output is None: + output = "." + generate_html(json_file, output, github_repo=repo) + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + click.echo(f"Files: {output}") + def resolve_credentials(token, org_uuid): """Resolve token and org_uuid from arguments or auto-detect. @@ -1120,7 +1218,7 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): "-o", "--output", type=click.Path(), - help="Output directory (default: creates folder with session ID)", + help="Output directory (default: creates folder with session ID, or temp dir with --gist)", ) @click.option("--token", help="API access token (auto-detected from keychain on macOS)") @click.option( @@ -1130,7 +1228,12 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): "--repo", help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", ) -def import_session(session_id, output, token, org_uuid, repo): +@click.option( + "--gist", + is_flag=True, + help="Upload to GitHub Gist and output a gistpreview.github.io URL.", +) +def import_session(session_id, output, token, org_uuid, repo, gist): """Import a session from the Claude API and convert to HTML. If SESSION_ID is not provided, displays an interactive picker to select a session. @@ -1190,12 +1293,25 @@ def import_session(session_id, output, token, org_uuid, repo): raise click.ClickException(f"Network error: {e}") # Determine output directory - if output is None: + if gist and output is None: + output = Path(tempfile.gettempdir()) / session_id + elif output is None: output = session_id click.echo(f"Generating HTML in {output}/...") generate_html_from_session_data(session_data, output, github_repo=repo) - click.echo(f"Done! Open {output}/index.html to view.") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + click.echo(f"Files: {output}") + else: + click.echo(f"Done! Open {output}/index.html to view.") def main(): diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index cc70449..90f0e4a 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -21,6 +21,9 @@ analyze_conversation, format_tool_stats, is_tool_result_message, + inject_gist_preview_js, + create_gist, + GIST_PREVIEW_JS, ) @@ -423,3 +426,254 @@ def test_list_web_requires_token_on_non_macos(self, monkeypatch): assert result.exit_code != 0 assert "must provide --token" in result.output + + +class TestInjectGistPreviewJs: + """Tests for the inject_gist_preview_js function.""" + + def test_injects_js_into_html_files(self, output_dir): + """Test that JS is injected before tag.""" + # Create test HTML files + (output_dir / "index.html").write_text( + "

    Test

    " + ) + (output_dir / "page-001.html").write_text( + "

    Page 1

    " + ) + + inject_gist_preview_js(output_dir) + + index_content = (output_dir / "index.html").read_text() + page_content = (output_dir / "page-001.html").read_text() + + # Check JS was injected + assert GIST_PREVIEW_JS in index_content + assert GIST_PREVIEW_JS in page_content + + # Check JS is before + assert index_content.endswith("") + assert "\n" + "", f"\n" ) html_file.write_text(content) @@ -931,7 +931,13 @@ def cli(): is_flag=True, help="Upload to GitHub Gist and output a gistpreview.github.io URL.", ) -def session(json_file, output, repo, gist): +@click.option( + "--json", + "include_json", + is_flag=True, + help="Include the original JSON session file in the output directory.", +) +def session(json_file, output, repo, gist, include_json): """Convert a Claude Code session JSON file to HTML.""" # Determine output directory if gist and output is None: @@ -943,8 +949,18 @@ def session(json_file, output, repo, gist): elif output is None: output = "." + output = Path(output) generate_html(json_file, output, github_repo=repo) + # Copy JSON file to output directory if requested + if include_json: + output.mkdir(exist_ok=True) + json_source = Path(json_file) + json_dest = output / json_source.name + shutil.copy(json_file, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + if gist: # Inject gist preview JS and create gist inject_gist_preview_js(output) @@ -1233,7 +1249,13 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): is_flag=True, help="Upload to GitHub Gist and output a gistpreview.github.io URL.", ) -def import_session(session_id, output, token, org_uuid, repo, gist): +@click.option( + "--json", + "include_json", + is_flag=True, + help="Include the JSON session data in the output directory.", +) +def import_session(session_id, output, token, org_uuid, repo, gist, include_json): """Import a session from the Claude API and convert to HTML. If SESSION_ID is not provided, displays an interactive picker to select a session. @@ -1298,9 +1320,19 @@ def import_session(session_id, output, token, org_uuid, repo, gist): elif output is None: output = session_id + output = Path(output) click.echo(f"Generating HTML in {output}/...") generate_html_from_session_data(session_data, output, github_repo=repo) + # Save JSON session data if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / f"{session_id}.json" + with open(json_dest, "w") as f: + json.dump(session_data, f, indent=2) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + if gist: # Inject gist preview JS and create gist inject_gist_preview_js(output) diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 90f0e4a..eb7aab8 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -577,7 +577,9 @@ def mock_run(*args, **kwargs): monkeypatch.setattr(subprocess, "run", mock_run) # Mock tempfile.gettempdir to use our tmp_path - monkeypatch.setattr("claude_code_publish.tempfile.gettempdir", lambda: str(tmp_path)) + monkeypatch.setattr( + "claude_code_publish.tempfile.gettempdir", lambda: str(tmp_path) + ) runner = CliRunner() result = runner.invoke( @@ -624,6 +626,93 @@ def mock_run(*args, **kwargs): assert "gistpreview.github.io" in index_content +class TestSessionJsonOption: + """Tests for the session command --json option.""" + + def test_session_json_copies_file(self, output_dir): + """Test that session --json copies the JSON file to output.""" + from click.testing import CliRunner + from claude_code_publish import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + runner = CliRunner() + result = runner.invoke( + cli, + ["session", str(fixture_path), "-o", str(output_dir), "--json"], + ) + + assert result.exit_code == 0 + json_file = output_dir / "sample_session.json" + assert json_file.exists() + assert "JSON:" in result.output + assert "KB" in result.output + + def test_session_json_preserves_original_name(self, output_dir): + """Test that --json preserves the original filename.""" + from click.testing import CliRunner + from claude_code_publish import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + runner = CliRunner() + result = runner.invoke( + cli, + ["session", str(fixture_path), "-o", str(output_dir), "--json"], + ) + + assert result.exit_code == 0 + # Should use original filename, not "session.json" + assert (output_dir / "sample_session.json").exists() + assert not (output_dir / "session.json").exists() + + +class TestImportJsonOption: + """Tests for the import command --json option.""" + + def test_import_json_saves_session_data(self, httpx_mock, output_dir): + """Test that import --json saves the session JSON.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Load sample session to mock API response + fixture_path = Path(__file__).parent / "sample_session.json" + with open(fixture_path) as f: + session_data = json.load(f) + + httpx_mock.add_response( + url="https://api.anthropic.com/v1/session_ingress/session/test-session-id", + json=session_data, + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "import", + "test-session-id", + "--token", + "test-token", + "--org-uuid", + "test-org", + "-o", + str(output_dir), + "--json", + ], + ) + + assert result.exit_code == 0 + json_file = output_dir / "test-session-id.json" + assert json_file.exists() + assert "JSON:" in result.output + assert "KB" in result.output + + # Verify JSON content is valid + with open(json_file) as f: + saved_data = json.load(f) + assert saved_data == session_data + + class TestImportGistOption: """Tests for the import command --gist option.""" @@ -657,7 +746,9 @@ def mock_run(*args, **kwargs): monkeypatch.setattr(subprocess, "run", mock_run) # Mock tempfile.gettempdir - monkeypatch.setattr("claude_code_publish.tempfile.gettempdir", lambda: str(tmp_path)) + monkeypatch.setattr( + "claude_code_publish.tempfile.gettempdir", lambda: str(tmp_path) + ) runner = CliRunner() result = runner.invoke( From 201c29f4b27b7e662fe3b9316604bc3295669d56 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 19:20:02 -0800 Subject: [PATCH 11/62] Fixed bug where index.html did not always show long summaries Also made it so output shows full path to index.html https://gistpreview.github.io/?0ce06835c48f621d068e7b10ccc9b86d --- src/claude_code_publish/__init__.py | 30 +++++++-- tests/test_generate_html.py | 96 +++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 0e04ddb..1d62d58 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -852,8 +852,16 @@ def generate_html(json_path, output_dir, github_repo=None): link = f"page-{page_num:03d}.html#{msg_id}" rendered_content = render_markdown_text(conv["user_text"]) + # Collect all messages including from subsequent continuation conversations + # This ensures long_texts from continuations appear with the original prompt + all_messages = list(conv["messages"]) + for j in range(i + 1, len(conversations)): + if not conversations[j].get("is_continuation"): + break + all_messages.extend(conversations[j]["messages"]) + # Analyze conversation for stats (excluding commits from inline display now) - stats = analyze_conversation(conv["messages"]) + stats = analyze_conversation(all_messages) tool_stats_str = format_tool_stats(stats["tool_counts"]) stats_html = "" @@ -904,8 +912,9 @@ def generate_html(json_path, output_dir, github_repo=None): """ - (output_dir / "index.html").write_text(index_content) - print(f"Generated index.html ({total_convs} prompts, {total_pages} pages)") + index_path = output_dir / "index.html" + index_path.write_text(index_content) + print(f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)") @click.group(cls=DefaultGroup, default="session", default_if_no_args=False) @@ -1172,8 +1181,16 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): link = f"page-{page_num:03d}.html#{msg_id}" rendered_content = render_markdown_text(conv["user_text"]) + # Collect all messages including from subsequent continuation conversations + # This ensures long_texts from continuations appear with the original prompt + all_messages = list(conv["messages"]) + for j in range(i + 1, len(conversations)): + if not conversations[j].get("is_continuation"): + break + all_messages.extend(conversations[j]["messages"]) + # Analyze conversation for stats (excluding commits from inline display now) - stats = analyze_conversation(conv["messages"]) + stats = analyze_conversation(all_messages) tool_stats_str = format_tool_stats(stats["tool_counts"]) stats_html = "" @@ -1224,8 +1241,9 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): """ - (output_dir / "index.html").write_text(index_content) - click.echo(f"Generated index.html ({total_convs} prompts, {total_pages} pages)") + index_path = output_dir / "index.html" + index_path.write_text(index_content) + click.echo(f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)") @cli.command("import") diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index eb7aab8..79519b6 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -626,6 +626,102 @@ def mock_run(*args, **kwargs): assert "gistpreview.github.io" in index_content +class TestContinuationLongTexts: + """Tests for long text extraction from continuation conversations.""" + + def test_long_text_in_continuation_appears_in_index(self, output_dir): + """Test that long texts from continuation conversations appear in index. + + This is a regression test for a bug where conversations marked as + continuations (isCompactSummary=True) were completely skipped when + building the index, causing their long_texts to be lost. + """ + # Create a session with: + # 1. An initial user prompt + # 2. Some messages + # 3. A continuation prompt (isCompactSummary=True) + # 4. An assistant message with a long text summary (>300 chars) + session_data = { + "loglines": [ + # Initial user prompt + { + "type": "user", + "timestamp": "2025-01-01T10:00:00.000Z", + "message": {"content": "Build a Redis JavaScript module", "role": "user"}, + }, + # Some assistant work + { + "type": "assistant", + "timestamp": "2025-01-01T10:00:05.000Z", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "I'll start working on this."}], + }, + }, + # Continuation prompt (context was summarized) + { + "type": "user", + "timestamp": "2025-01-01T11:00:00.000Z", + "isCompactSummary": True, + "message": { + "content": "This session is being continued from a previous conversation...", + "role": "user", + }, + }, + # More assistant work after continuation + { + "type": "assistant", + "timestamp": "2025-01-01T11:00:05.000Z", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": "Continuing the work..."}], + }, + }, + # Final summary - this is a LONG text (>300 chars) that should appear in index + { + "type": "assistant", + "timestamp": "2025-01-01T12:00:00.000Z", + "message": { + "role": "assistant", + "content": [ + { + "type": "text", + "text": ( + "All tasks completed successfully. Here's a summary of what was built:\n\n" + "## Redis JavaScript Module\n\n" + "A loadable Redis module providing JavaScript scripting via the mquickjs engine.\n\n" + "### Commands Implemented\n" + "- JS.EVAL - Execute JavaScript with KEYS/ARGV arrays\n" + "- JS.LOAD / JS.CALL - Cache and call scripts by SHA1\n" + "- JS.EXISTS / JS.FLUSH - Manage script cache\n\n" + "All 41 tests pass. Changes pushed to branch." + ), + } + ], + }, + }, + ] + } + + # Write the session to a temp file + session_file = output_dir / "test_session.json" + session_file.write_text(json.dumps(session_data)) + + # Generate HTML + generate_html(session_file, output_dir) + + # Read the index.html + index_html = (output_dir / "index.html").read_text() + + # The long text summary should appear in the index + # This is the bug: currently it doesn't because the continuation + # conversation is skipped entirely + assert "All tasks completed successfully" in index_html, ( + "Long text from continuation conversation should appear in index" + ) + assert "Redis JavaScript Module" in index_html + + class TestSessionJsonOption: """Tests for the session command --json option.""" From 3ec9ca6937edd66c4c5572b8a6defc36e6a31de1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 19:21:10 -0800 Subject: [PATCH 12/62] Ran black --- src/claude_code_publish/__init__.py | 8 ++++++-- tests/test_generate_html.py | 15 ++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 1d62d58..6ae7c1c 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -914,7 +914,9 @@ def generate_html(json_path, output_dir, github_repo=None): """ index_path = output_dir / "index.html" index_path.write_text(index_content) - print(f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)") + print( + f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)" + ) @click.group(cls=DefaultGroup, default="session", default_if_no_args=False) @@ -1243,7 +1245,9 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): """ index_path = output_dir / "index.html" index_path.write_text(index_content) - click.echo(f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)") + click.echo( + f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)" + ) @cli.command("import") diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 79519b6..5614189 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -647,7 +647,10 @@ def test_long_text_in_continuation_appears_in_index(self, output_dir): { "type": "user", "timestamp": "2025-01-01T10:00:00.000Z", - "message": {"content": "Build a Redis JavaScript module", "role": "user"}, + "message": { + "content": "Build a Redis JavaScript module", + "role": "user", + }, }, # Some assistant work { @@ -655,7 +658,9 @@ def test_long_text_in_continuation_appears_in_index(self, output_dir): "timestamp": "2025-01-01T10:00:05.000Z", "message": { "role": "assistant", - "content": [{"type": "text", "text": "I'll start working on this."}], + "content": [ + {"type": "text", "text": "I'll start working on this."} + ], }, }, # Continuation prompt (context was summarized) @@ -716,9 +721,9 @@ def test_long_text_in_continuation_appears_in_index(self, output_dir): # The long text summary should appear in the index # This is the bug: currently it doesn't because the continuation # conversation is skipped entirely - assert "All tasks completed successfully" in index_html, ( - "Long text from continuation conversation should appear in index" - ) + assert ( + "All tasks completed successfully" in index_html + ), "Long text from continuation conversation should appear in index" assert "Redis JavaScript Module" in index_html From 944741d6154275443f68951f29cb9a139f644e50 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 19:27:28 -0800 Subject: [PATCH 13/62] --open option to open directly in browser https://gistpreview.github.io/?323133f9d9abb6a61930a70e637c9d5e --- README.md | 11 +++++ src/claude_code_publish/__init__.py | 37 +++++++++++--- tests/test_generate_html.py | 76 +++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e27b13e..5bb9e4e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ When using [Claude Code for web](https://claude.ai/code) you can export your ses This tool converts that JSON into a browseable multi-page HTML transcript. +The quickest way to view a recent session is to import it directly and open in your browser: + +```bash +claude-code-publish import --open +``` + +This shows an interactive picker to select a session, generates HTML, and opens it in your default browser. + +For a local session file: + ```bash claude-code-publish session.json -o output-directory/ ``` @@ -39,6 +49,7 @@ This will generate: - `-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) +- `--open` - open the generated `index.html` in your default browser - `--gist` - upload the generated HTML files to a GitHub Gist and output a preview URL - `--json` - include the original JSON session file in the output directory diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 6ae7c1c..be7d47d 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -8,6 +8,7 @@ import shutil import subprocess import tempfile +import webbrowser from pathlib import Path import click @@ -931,7 +932,7 @@ def cli(): "-o", "--output", type=click.Path(), - help="Output directory (default: current directory, or temp dir with --gist)", + help="Output directory (default: current directory, or temp dir with --gist/--open)", ) @click.option( "--repo", @@ -948,10 +949,16 @@ def cli(): is_flag=True, help="Include the original JSON session file in the output directory.", ) -def session(json_file, output, repo, gist, include_json): +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated index.html in your default browser.", +) +def session(json_file, output, repo, gist, include_json, open_browser): """Convert a Claude Code session JSON file to HTML.""" # Determine output directory - if gist and output is None: + if (gist or open_browser) and output is None: # Extract session ID from JSON file for temp directory name with open(json_file, "r") as f: data = json.load(f) @@ -982,6 +989,10 @@ def session(json_file, output, repo, gist, include_json): click.echo(f"Preview: {preview_url}") click.echo(f"Files: {output}") + if open_browser: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + def resolve_credentials(token, org_uuid): """Resolve token and org_uuid from arguments or auto-detect. @@ -1256,7 +1267,7 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): "-o", "--output", type=click.Path(), - help="Output directory (default: creates folder with session ID, or temp dir with --gist)", + help="Output directory (default: creates folder with session ID, or temp dir with --gist/--open)", ) @click.option("--token", help="API access token (auto-detected from keychain on macOS)") @click.option( @@ -1277,7 +1288,15 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): is_flag=True, help="Include the JSON session data in the output directory.", ) -def import_session(session_id, output, token, org_uuid, repo, gist, include_json): +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated index.html in your default browser.", +) +def import_session( + session_id, output, token, org_uuid, repo, gist, include_json, open_browser +): """Import a session from the Claude API and convert to HTML. If SESSION_ID is not provided, displays an interactive picker to select a session. @@ -1337,7 +1356,7 @@ def import_session(session_id, output, token, org_uuid, repo, gist, include_json raise click.ClickException(f"Network error: {e}") # Determine output directory - if gist and output is None: + if (gist or open_browser) and output is None: output = Path(tempfile.gettempdir()) / session_id elif output is None: output = session_id @@ -1364,8 +1383,10 @@ def import_session(session_id, output, token, org_uuid, repo, gist, include_json click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") click.echo(f"Files: {output}") - else: - click.echo(f"Done! Open {output}/index.html to view.") + + if open_browser: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) def main(): diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 5614189..316537f 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -869,3 +869,79 @@ def mock_run(*args, **kwargs): assert "Creating GitHub gist" in result.output assert "gist.github.com" in result.output assert "gistpreview.github.io" in result.output + + +class TestOpenOption: + """Tests for the --open option.""" + + def test_session_open_calls_webbrowser(self, output_dir, monkeypatch): + """Test that session --open opens the browser.""" + from click.testing import CliRunner + from claude_code_publish import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + # Track webbrowser.open calls + opened_urls = [] + + def mock_open(url): + opened_urls.append(url) + return True + + monkeypatch.setattr("claude_code_publish.webbrowser.open", mock_open) + + runner = CliRunner() + result = runner.invoke( + cli, + ["session", str(fixture_path), "-o", str(output_dir), "--open"], + ) + + assert result.exit_code == 0 + assert len(opened_urls) == 1 + assert "index.html" in opened_urls[0] + assert opened_urls[0].startswith("file://") + + def test_import_open_calls_webbrowser(self, httpx_mock, output_dir, monkeypatch): + """Test that import --open opens the browser.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Load sample session to mock API response + fixture_path = Path(__file__).parent / "sample_session.json" + with open(fixture_path) as f: + session_data = json.load(f) + + httpx_mock.add_response( + url="https://api.anthropic.com/v1/session_ingress/session/test-session-id", + json=session_data, + ) + + # Track webbrowser.open calls + opened_urls = [] + + def mock_open(url): + opened_urls.append(url) + return True + + monkeypatch.setattr("claude_code_publish.webbrowser.open", mock_open) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "import", + "test-session-id", + "--token", + "test-token", + "--org-uuid", + "test-org", + "-o", + str(output_dir), + "--open", + ], + ) + + assert result.exit_code == 0 + assert len(opened_urls) == 1 + assert "index.html" in opened_urls[0] + assert opened_urls[0].startswith("file://") From 1ceafed6c01dbf5bc164d53c50f7d8ba968a8f65 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 19:28:13 -0800 Subject: [PATCH 14/62] Release 0.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 745296b..8ad1e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "claude-code-publish" -version = "0.1" +version = "0.2" description = "Convert a Claude Code for web session.json to HTML" readme = "README.md" license = "Apache-2.0" From fc2656663303a46e381db1526d6aef9b812bddf6 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 21:09:03 -0800 Subject: [PATCH 15/62] AGENTS.md and CLAUDE.md --- AGENTS.md | 15 +++++++++++++++ CLAUDE.md | 1 + 2 files changed, 16 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c90368f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +Uses uv. Run tests like this: + + uv run pytest + +Run the development version of the tool like this: + + uv run claude-code-publish --help + +Always practice TDD: write a faliing test, watch it fail, then make it pass. + +Commit early and often. Commits should bundle the test, implementation, and documentation changes together. + +Run Black to format code before you commit: + + uv run black . 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 From 2b38831c8da86ed2c605c8bb616d5cb0ec9e2b43 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 24 Dec 2025 21:33:40 -0800 Subject: [PATCH 16/62] Add version flag to CLI (#1) * Add -v/--version flag to CLI Add a version flag that displays the package version from importlib.metadata. Both -v and --version are supported. Includes tests for both flags. * Add uv.lock * Remove uv.lock and add to .gitignore * Use importlib.metadata in version tests instead of hardcoded version * Refactor to use click.version_option decorator * Simplify version_option using package_name for auto-detection https://gistpreview.github.io/?7bdf1535f7bf897fb475be6ff5da2e1c/index.html --- .gitignore | 1 + src/claude_code_publish/__init__.py | 1 + tests/test_generate_html.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/.gitignore b/.gitignore index 6a4dae1..3be145d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store __pycache__ +uv.lock diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index be7d47d..62aecc6 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -921,6 +921,7 @@ def generate_html(json_path, output_dir, github_repo=None): @click.group(cls=DefaultGroup, default="session", default_if_no_args=False) +@click.version_option(None, "-v", "--version", package_name="claude-code-publish") def cli(): """Convert Claude Code session JSON to mobile-friendly HTML pages.""" pass diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 316537f..cb0b962 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -871,6 +871,36 @@ def mock_run(*args, **kwargs): assert "gistpreview.github.io" in result.output +class TestVersionOption: + """Tests for the --version option.""" + + def test_version_long_flag(self): + """Test that --version shows version info.""" + import importlib.metadata + from click.testing import CliRunner + from claude_code_publish import cli + + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + + expected_version = importlib.metadata.version("claude-code-publish") + assert result.exit_code == 0 + assert expected_version in result.output + + def test_version_short_flag(self): + """Test that -v shows version info.""" + import importlib.metadata + from click.testing import CliRunner + from claude_code_publish import cli + + runner = CliRunner() + result = runner.invoke(cli, ["-v"]) + + expected_version = importlib.metadata.version("claude-code-publish") + assert result.exit_code == 0 + assert expected_version in result.output + + class TestOpenOption: """Tests for the --open option.""" From 441d339fdb7a9c6ca874fac5138b14e47543b8d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 14:45:24 +0000 Subject: [PATCH 17/62] Add support for local JSONL session format - Add parse_session_file() abstraction to handle both JSON and JSONL formats - Add get_session_summary() to extract summaries from session files - Add find_local_sessions() to discover JSONL files in ~/.claude/projects - Add list-local command to show local sessions - Change default behavior: running with no args now lists local sessions - Add comprehensive tests for all new functionality - Include sample JSONL test fixture and snapshot tests --- src/claude_code_publish/__init__.py | 209 ++++++++++++++- ...SessionFile.test_jsonl_generates_html.html | 175 ++++++++++++ tests/sample_session.jsonl | 8 + tests/test_generate_html.py | 251 ++++++++++++++++++ 4 files changed, 639 insertions(+), 4 deletions(-) create mode 100644 tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html create mode 100644 tests/sample_session.jsonl diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 62aecc6..55e1bb6 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -38,6 +38,157 @@ ANTHROPIC_VERSION = "2023-06-01" +def get_session_summary(filepath, max_length=200): + """Extract a human-readable summary from a session file. + + Supports both JSON and JSONL formats. + Returns a summary string or "(no summary)" if none found. + """ + filepath = Path(filepath) + try: + if filepath.suffix == ".jsonl": + return _get_jsonl_summary(filepath, max_length) + else: + # For JSON files, try to get first user message + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + loglines = data.get("loglines", []) + for entry in loglines: + if entry.get("type") == "user": + msg = entry.get("message", {}) + content = msg.get("content", "") + if isinstance(content, str) and content.strip(): + if len(content) > max_length: + return content[: max_length - 3] + "..." + return content + return "(no summary)" + except Exception: + return "(no summary)" + + +def _get_jsonl_summary(filepath, max_length=200): + """Extract summary from JSONL file.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + # First priority: summary type entries + if obj.get("type") == "summary" and obj.get("summary"): + summary = obj["summary"] + if len(summary) > max_length: + return summary[: max_length - 3] + "..." + return summary + except json.JSONDecodeError: + continue + + # Second pass: find first non-meta user message + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + if ( + obj.get("type") == "user" + and not obj.get("isMeta") + and obj.get("message", {}).get("content") + ): + content = obj["message"]["content"] + if isinstance(content, str): + content = content.strip() + if content and not content.startswith("<"): + if len(content) > max_length: + return content[: max_length - 3] + "..." + return content + except json.JSONDecodeError: + continue + except Exception: + pass + + return "(no summary)" + + +def find_local_sessions(folder, limit=10): + """Find recent JSONL session files in the given folder. + + Returns a list of (Path, summary) tuples sorted by modification time. + Excludes agent files and warmup/empty sessions. + """ + folder = Path(folder) + if not folder.exists(): + return [] + + results = [] + for f in folder.glob("**/*.jsonl"): + if f.name.startswith("agent-"): + continue + summary = get_session_summary(f) + # Skip boring/empty sessions + if summary.lower() == "warmup" or summary == "(no summary)": + continue + results.append((f, summary)) + + # Sort by modification time, most recent first + results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True) + return results[:limit] + + +def parse_session_file(filepath): + """Parse a session file and return normalized data. + + Supports both JSON and JSONL formats. + Returns a dict with 'loglines' key containing the normalized entries. + """ + filepath = Path(filepath) + + if filepath.suffix == ".jsonl": + return _parse_jsonl_file(filepath) + else: + # Standard JSON format + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + + +def _parse_jsonl_file(filepath): + """Parse JSONL file and convert to standard format.""" + loglines = [] + + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + entry_type = obj.get("type") + + # Skip non-message entries + if entry_type not in ("user", "assistant"): + continue + + # Convert to standard format + entry = { + "type": entry_type, + "timestamp": obj.get("timestamp", ""), + "message": obj.get("message", {}), + } + + # Preserve isCompactSummary if present + if obj.get("isCompactSummary"): + entry["isCompactSummary"] = True + + loglines.append(entry) + except json.JSONDecodeError: + continue + + return {"loglines": loglines} + + class CredentialsError(Exception): """Raised when credentials cannot be obtained.""" @@ -730,9 +881,8 @@ def generate_html(json_path, output_dir, github_repo=None): output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True) - # Load JSON file - with open(json_path, "r") as f: - data = json.load(f) + # Load session file (supports both JSON and JSONL) + data = parse_session_file(json_path) loglines = data.get("loglines", []) @@ -920,13 +1070,64 @@ def generate_html(json_path, output_dir, github_repo=None): ) -@click.group(cls=DefaultGroup, default="session", default_if_no_args=False) +@click.group(cls=DefaultGroup, default="list-local", default_if_no_args=True) @click.version_option(None, "-v", "--version", package_name="claude-code-publish") def cli(): """Convert Claude Code session JSON to mobile-friendly HTML pages.""" pass +@cli.command("list-local") +@click.option( + "--limit", + default=10, + help="Maximum number of sessions to show (default: 10)", +) +def list_local(limit): + """List available local Claude Code sessions.""" + projects_folder = Path.home() / ".claude" / "projects" + + if not projects_folder.exists(): + click.echo(f"Projects folder not found: {projects_folder}") + click.echo("No local Claude Code sessions available.") + return + + click.echo("Loading local sessions...") + results = find_local_sessions(projects_folder, limit=limit) + + if not results: + click.echo("No local sessions found.") + return + + # Calculate terminal width for formatting + try: + term_width = shutil.get_terminal_size().columns + except Exception: + term_width = 80 + + # Fixed width: date(16) + spaces(2) + size(8) + spaces(2) = 28 + fixed_width = 28 + summary_width = max(20, term_width - fixed_width - 1) + + click.echo("") + click.echo("Recent local sessions:") + click.echo("") + + from datetime import datetime + + for filepath, summary in results: + stat = filepath.stat() + mod_time = datetime.fromtimestamp(stat.st_mtime) + size_kb = stat.st_size / 1024 + date_str = mod_time.strftime("%Y-%m-%d %H:%M") + + # Truncate summary if needed + if len(summary) > summary_width: + summary = summary[: summary_width - 3] + "..." + + click.echo(f"{date_str} {size_kb:6.0f} KB {summary}") + + @cli.command() @click.argument("json_file", type=click.Path(exists=True)) @click.option( diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html new file mode 100644 index 0000000..6483aef --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -0,0 +1,175 @@ + + + + + + Claude Code transcript - Index + + + +
    +

    Claude Code transcript

    + +

    2 prompts ยท 7 messages ยท 2 tool calls ยท 1 commits ยท 1 pages

    +
    abc1234
    Add hello function
    + +
    + + + \ No newline at end of file diff --git a/tests/sample_session.jsonl b/tests/sample_session.jsonl new file mode 100644 index 0000000..8d4070e --- /dev/null +++ b/tests/sample_session.jsonl @@ -0,0 +1,8 @@ +{"type":"summary","summary":"Test session for JSONL parsing","leafUuid":"test-leaf-uuid"} +{"type":"user","timestamp":"2025-12-24T10:00:00.000Z","sessionId":"test-session-id","cwd":"/project","gitBranch":"main","message":{"role":"user","content":"Create a hello world function"},"uuid":"msg-001"} +{"type":"assistant","timestamp":"2025-12-24T10:00:05.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"text","text":"I'll create that function for you."},{"type":"tool_use","id":"toolu_001","name":"Write","input":{"file_path":"/project/hello.py","content":"def hello():\n return 'Hello, World!'\n"}}]},"uuid":"msg-002"} +{"type":"user","timestamp":"2025-12-24T10:00:10.000Z","sessionId":"test-session-id","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_001","content":"File written successfully"}]},"uuid":"msg-003"} +{"type":"assistant","timestamp":"2025-12-24T10:00:15.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_002","name":"Bash","input":{"command":"git add . && git commit -m 'Add hello function'","description":"Commit changes"}}]},"uuid":"msg-004"} +{"type":"user","timestamp":"2025-12-24T10:00:20.000Z","sessionId":"test-session-id","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_002","content":"[main abc1234] Add hello function\n 1 file changed"}]},"uuid":"msg-005"} +{"type":"user","timestamp":"2025-12-24T10:01:00.000Z","sessionId":"test-session-id","message":{"role":"user","content":"Now add a goodbye function"},"uuid":"msg-006"} +{"type":"assistant","timestamp":"2025-12-24T10:01:05.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"text","text":"Done! The hello function is ready."}]},"uuid":"msg-007"} diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index cb0b962..2773f51 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -24,6 +24,9 @@ inject_gist_preview_js, create_gist, GIST_PREVIEW_JS, + parse_session_file, + get_session_summary, + find_local_sessions, ) @@ -975,3 +978,251 @@ def mock_open(url): assert len(opened_urls) == 1 assert "index.html" in opened_urls[0] assert opened_urls[0].startswith("file://") + + +class TestParseSessionFile: + """Tests for parse_session_file which abstracts both JSON and JSONL formats.""" + + def test_parses_json_format(self): + """Test that standard JSON format is parsed correctly.""" + fixture_path = Path(__file__).parent / "sample_session.json" + result = parse_session_file(fixture_path) + + assert "loglines" in result + assert len(result["loglines"]) > 0 + # Check first entry + first = result["loglines"][0] + assert first["type"] == "user" + assert "timestamp" in first + assert "message" in first + + def test_parses_jsonl_format(self): + """Test that JSONL format is parsed and converted to standard format.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result = parse_session_file(fixture_path) + + assert "loglines" in result + assert len(result["loglines"]) > 0 + # Check structure matches JSON format + for entry in result["loglines"]: + assert "type" in entry + # Skip summary entries which don't have message + if entry["type"] in ("user", "assistant"): + assert "timestamp" in entry + assert "message" in entry + + def test_jsonl_skips_non_message_entries(self): + """Test that summary and file-history-snapshot entries are skipped.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result = parse_session_file(fixture_path) + + # None of the loglines should be summary or file-history-snapshot + for entry in result["loglines"]: + assert entry["type"] in ("user", "assistant") + + def test_jsonl_preserves_message_content(self): + """Test that message content is preserved correctly.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result = parse_session_file(fixture_path) + + # Find the first user message + user_msg = next(e for e in result["loglines"] if e["type"] == "user") + assert user_msg["message"]["content"] == "Create a hello world function" + + def test_jsonl_generates_html(self, output_dir, snapshot_html): + """Test that JSONL files can be converted to HTML.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + generate_html(fixture_path, output_dir) + + index_html = (output_dir / "index.html").read_text() + assert "hello world" in index_html.lower() + assert index_html == snapshot_html + + +class TestGetSessionSummary: + """Tests for get_session_summary which extracts summary from session files.""" + + def test_gets_summary_from_jsonl(self): + """Test extracting summary from JSONL file.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + summary = get_session_summary(fixture_path) + assert summary == "Test session for JSONL parsing" + + def test_gets_first_user_message_if_no_summary(self, tmp_path): + """Test falling back to first user message when no summary entry.""" + jsonl_file = tmp_path / "test.jsonl" + jsonl_file.write_text( + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello world test"}}\n' + ) + summary = get_session_summary(jsonl_file) + assert summary == "Hello world test" + + def test_returns_no_summary_for_empty_file(self, tmp_path): + """Test handling empty or invalid files.""" + jsonl_file = tmp_path / "empty.jsonl" + jsonl_file.write_text("") + summary = get_session_summary(jsonl_file) + assert summary == "(no summary)" + + def test_truncates_long_summaries(self, tmp_path): + """Test that long summaries are truncated.""" + jsonl_file = tmp_path / "long.jsonl" + long_text = "x" * 300 + jsonl_file.write_text(f'{{"type":"summary","summary":"{long_text}"}}\n') + summary = get_session_summary(jsonl_file, max_length=100) + assert len(summary) <= 100 + assert summary.endswith("...") + + +class TestFindLocalSessions: + """Tests for find_local_sessions which discovers local JSONL files.""" + + def test_finds_jsonl_files(self, tmp_path): + """Test finding JSONL files in projects directory.""" + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create a session file + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 1 + assert results[0][0] == session_file + assert results[0][1] == "Test session" + + def test_excludes_agent_files(self, tmp_path): + """Test that agent- prefixed files are excluded.""" + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create agent file (should be excluded) + agent_file = projects_dir / "agent-123.jsonl" + agent_file.write_text('{"type":"user","message":{"content":"test"}}\n') + + # Create regular file (should be included) + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Real session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 1 + assert "agent-" not in results[0][0].name + + def test_excludes_warmup_sessions(self, tmp_path): + """Test that warmup sessions are excluded.""" + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create warmup file (should be excluded) + warmup_file = projects_dir / "warmup-session.jsonl" + warmup_file.write_text('{"type":"summary","summary":"warmup"}\n') + + # Create regular file + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Real session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 1 + assert results[0][1] == "Real session" + + def test_sorts_by_modification_time(self, tmp_path): + """Test that results are sorted by modification time, newest first.""" + import time + + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create files with different mtimes + file1 = projects_dir / "older.jsonl" + file1.write_text( + '{"type":"summary","summary":"Older"}\n{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"test"}}\n' + ) + + time.sleep(0.1) # Ensure different mtime + + file2 = projects_dir / "newer.jsonl" + file2.write_text( + '{"type":"summary","summary":"Newer"}\n{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"test"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 2 + assert results[0][1] == "Newer" # Most recent first + assert results[1][1] == "Older" + + def test_respects_limit(self, tmp_path): + """Test that limit parameter is respected.""" + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create 5 files + for i in range(5): + f = projects_dir / f"session-{i}.jsonl" + f.write_text( + f'{{"type":"summary","summary":"Session {i}"}}\n{{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{{"role":"user","content":"test"}}}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=3) + assert len(results) == 3 + + +class TestLocalSessionCLI: + """Tests for CLI behavior with local sessions.""" + + def test_list_local_shows_sessions(self, tmp_path, monkeypatch): + """Test that 'list-local' command shows local sessions.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test local session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + runner = CliRunner() + result = runner.invoke(cli, ["list-local"]) + + assert result.exit_code == 0 + assert "Test local session" in result.output + + def test_no_args_lists_local_sessions(self, tmp_path, monkeypatch): + """Test that running with no arguments lists local sessions.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test default session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + runner = CliRunner() + result = runner.invoke(cli, []) + + assert result.exit_code == 0 + assert "Test default session" in result.output From 0385cd0f418ffb0c6e01c5bf6c2a859fbe3e46b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 17:19:45 +0000 Subject: [PATCH 18/62] Restructure CLI commands per feedback - Rename 'session' command to 'json' for direct file path access - Rename 'import' command to 'web' for web session selection - Update 'list-local' to 'local' with interactive picker and conversion - Remove 'list-web' command (now integrated into 'web' command) - Make 'local' the default command when run with no arguments New CLI structure: - claude-code-publish local - select from local JSONL sessions - claude-code-publish web - select from web sessions - claude-code-publish json - provide direct file path All commands support --gist, --json, --open, and -o options. --- src/claude_code_publish/__init__.py | 150 +++++++++++++---------- tests/test_generate_html.py | 180 +++++++++++----------------- 2 files changed, 159 insertions(+), 171 deletions(-) diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 55e1bb6..85f0511 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -1070,21 +1070,50 @@ def generate_html(json_path, output_dir, github_repo=None): ) -@click.group(cls=DefaultGroup, default="list-local", default_if_no_args=True) +@click.group(cls=DefaultGroup, default="local", default_if_no_args=True) @click.version_option(None, "-v", "--version", package_name="claude-code-publish") def cli(): """Convert Claude Code session JSON to mobile-friendly HTML pages.""" pass -@cli.command("list-local") +@cli.command("local") +@click.option( + "-o", + "--output", + type=click.Path(), + help="Output directory (default: temp dir, or '.' with -o .)", +) +@click.option( + "--repo", + help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", +) +@click.option( + "--gist", + is_flag=True, + help="Upload to GitHub Gist and output a gistpreview.github.io URL.", +) +@click.option( + "--json", + "include_json", + is_flag=True, + help="Include the original JSONL session file in the output directory.", +) +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated index.html in your default browser.", +) @click.option( "--limit", default=10, help="Maximum number of sessions to show (default: 10)", ) -def list_local(limit): - """List available local Claude Code sessions.""" +def local_cmd(output, repo, gist, include_json, open_browser, limit): + """Select and convert a local Claude Code session to HTML.""" + from datetime import datetime + projects_folder = Path.home() / ".claude" / "projects" if not projects_folder.exists(): @@ -1099,36 +1128,63 @@ def list_local(limit): click.echo("No local sessions found.") return - # Calculate terminal width for formatting - try: - term_width = shutil.get_terminal_size().columns - except Exception: - term_width = 80 - - # Fixed width: date(16) + spaces(2) + size(8) + spaces(2) = 28 - fixed_width = 28 - summary_width = max(20, term_width - fixed_width - 1) - - click.echo("") - click.echo("Recent local sessions:") - click.echo("") - - from datetime import datetime - + # Build choices for questionary + choices = [] for filepath, summary in results: stat = filepath.stat() mod_time = datetime.fromtimestamp(stat.st_mtime) size_kb = stat.st_size / 1024 date_str = mod_time.strftime("%Y-%m-%d %H:%M") + # Truncate summary if too long + if len(summary) > 50: + summary = summary[:47] + "..." + display = f"{date_str} {size_kb:5.0f} KB {summary}" + choices.append(questionary.Choice(title=display, value=filepath)) + + selected = questionary.select( + "Select a session to convert:", + choices=choices, + ).ask() + + if selected is None: + click.echo("No session selected.") + return + + session_file = selected + + # Determine output directory + if (gist or open_browser) and output is None: + output = Path(tempfile.gettempdir()) / session_file.stem + elif output is None: + output = Path(tempfile.gettempdir()) / session_file.stem - # Truncate summary if needed - if len(summary) > summary_width: - summary = summary[: summary_width - 3] + "..." + output = Path(output) + generate_html(session_file, output, github_repo=repo) + + # Copy JSONL file to output directory if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / session_file.name + shutil.copy(session_file, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSONL: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + click.echo(f"Files: {output}") - click.echo(f"{date_str} {size_kb:6.0f} KB {summary}") + if open_browser: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) -@cli.command() +@cli.command("json") @click.argument("json_file", type=click.Path(exists=True)) @click.option( "-o", @@ -1157,8 +1213,8 @@ def list_local(limit): is_flag=True, help="Open the generated index.html in your default browser.", ) -def session(json_file, output, repo, gist, include_json, open_browser): - """Convert a Claude Code session JSON file to HTML.""" +def json_cmd(json_file, output, repo, gist, include_json, open_browser): + """Convert a Claude Code session JSON/JSONL file to HTML.""" # Determine output directory if (gist or open_browser) and output is None: # Extract session ID from JSON file for temp directory name @@ -1242,40 +1298,6 @@ def format_session_for_display(session_data): return f"{session_id} {created_at[:19] if created_at else 'N/A':19} {title}" -@cli.command("list-web") -@click.option("--token", help="API access token (auto-detected from keychain on macOS)") -@click.option( - "--org-uuid", help="Organization UUID (auto-detected from ~/.claude.json)" -) -def list_web(token, org_uuid): - """List available sessions from the Claude API.""" - try: - token, org_uuid = resolve_credentials(token, org_uuid) - except click.ClickException: - raise - - try: - sessions_data = fetch_sessions(token, org_uuid) - except httpx.HTTPStatusError as e: - raise click.ClickException( - f"API request failed: {e.response.status_code} {e.response.text}" - ) - except httpx.RequestError as e: - raise click.ClickException(f"Network error: {e}") - - sessions = sessions_data.get("data", []) - if not sessions: - click.echo("No sessions found.") - return - - # Print header - click.echo(f"{'Session ID':<35} {'Created':<19} Name") - click.echo("-" * 80) - - for session_data in sessions: - click.echo(format_session_for_display(session_data)) - - def generate_html_from_session_data(session_data, output_dir, github_repo=None): """Generate HTML from session data dict (instead of file path).""" output_dir = Path(output_dir) @@ -1463,7 +1485,7 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): ) -@cli.command("import") +@cli.command("web") @click.argument("session_id", required=False) @click.option( "-o", @@ -1496,10 +1518,10 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): is_flag=True, help="Open the generated index.html in your default browser.", ) -def import_session( +def web_cmd( session_id, output, token, org_uuid, repo, gist, include_json, open_browser ): - """Import a session from the Claude API and convert to HTML. + """Select and convert a web session from the Claude API to HTML. If SESSION_ID is not provided, displays an interactive picker to select a session. """ diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 2773f51..46ff34a 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -339,98 +339,6 @@ def test_rejects_empty(self): assert is_tool_result_message({"content": "string"}) is False -class TestListWebCommand: - """Tests for the list-web command.""" - - def test_list_web_displays_sessions(self, httpx_mock): - """Test that list-web displays sessions from the API.""" - from click.testing import CliRunner - from claude_code_publish import cli - - # Mock the API response with realistic data - mock_response = { - "data": [ - { - "id": "session_01ABC123", - "title": "Build a CLI tool", - "created_at": "2025-12-24T10:30:00Z", - "updated_at": "2025-12-24T11:00:00Z", - "type": "web", - "session_status": "completed", - "environment_id": "env_123", - "session_context": {}, - }, - { - "id": "session_02DEF456", - "title": "Fix authentication bug", - "created_at": "2025-12-23T14:00:00Z", - "updated_at": "2025-12-23T15:30:00Z", - "type": "web", - "session_status": "completed", - "environment_id": "env_123", - "session_context": {}, - }, - ], - "has_more": False, - "first_id": "session_01ABC123", - "last_id": "session_02DEF456", - } - - httpx_mock.add_response( - url="https://api.anthropic.com/v1/sessions", - json=mock_response, - ) - - runner = CliRunner() - result = runner.invoke( - cli, - ["list-web", "--token", "test-token", "--org-uuid", "test-org-uuid"], - ) - - assert result.exit_code == 0 - assert "session_01ABC123" in result.output - assert "session_02DEF456" in result.output - assert "Build a CLI tool" in result.output - assert "Fix authentication bug" in result.output - assert "2025-12-24T10:30:00" in result.output - - def test_list_web_no_sessions(self, httpx_mock): - """Test list-web when no sessions are found.""" - from click.testing import CliRunner - from claude_code_publish import cli - - httpx_mock.add_response( - url="https://api.anthropic.com/v1/sessions", - json={"data": [], "has_more": False}, - ) - - runner = CliRunner() - result = runner.invoke( - cli, - ["list-web", "--token", "test-token", "--org-uuid", "test-org-uuid"], - ) - - assert result.exit_code == 0 - assert "No sessions found" in result.output - - def test_list_web_requires_token_on_non_macos(self, monkeypatch): - """Test that list-web requires --token on non-macOS platforms.""" - from click.testing import CliRunner - from claude_code_publish import cli - - # Pretend we're on Linux - monkeypatch.setattr("claude_code_publish.platform.system", lambda: "Linux") - - runner = CliRunner() - result = runner.invoke( - cli, - ["list-web", "--org-uuid", "test-org-uuid"], - ) - - assert result.exit_code != 0 - assert "must provide --token" in result.output - - class TestInjectGistPreviewJs: """Tests for the inject_gist_preview_js function.""" @@ -587,7 +495,7 @@ def mock_run(*args, **kwargs): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "--gist"], + ["json", str(fixture_path), "--gist"], ) assert result.exit_code == 0 @@ -619,7 +527,7 @@ def mock_run(*args, **kwargs): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--gist"], + ["json", str(fixture_path), "-o", str(output_dir), "--gist"], ) assert result.exit_code == 0 @@ -743,7 +651,7 @@ def test_session_json_copies_file(self, output_dir): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--json"], + ["json", str(fixture_path), "-o", str(output_dir), "--json"], ) assert result.exit_code == 0 @@ -762,7 +670,7 @@ def test_session_json_preserves_original_name(self, output_dir): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--json"], + ["json", str(fixture_path), "-o", str(output_dir), "--json"], ) assert result.exit_code == 0 @@ -793,7 +701,7 @@ def test_import_json_saves_session_data(self, httpx_mock, output_dir): result = runner.invoke( cli, [ - "import", + "web", "test-session-id", "--token", "test-token", @@ -858,7 +766,7 @@ def mock_run(*args, **kwargs): result = runner.invoke( cli, [ - "import", + "web", "test-session-id", "--token", "test-token", @@ -926,7 +834,7 @@ def mock_open(url): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--open"], + ["json", str(fixture_path), "-o", str(output_dir), "--open"], ) assert result.exit_code == 0 @@ -962,7 +870,7 @@ def mock_open(url): result = runner.invoke( cli, [ - "import", + "web", "test-session-id", "--token", "test-token", @@ -1179,10 +1087,11 @@ def test_respects_limit(self, tmp_path): class TestLocalSessionCLI: """Tests for CLI behavior with local sessions.""" - def test_list_local_shows_sessions(self, tmp_path, monkeypatch): - """Test that 'list-local' command shows local sessions.""" + def test_local_shows_sessions_and_converts(self, tmp_path, monkeypatch): + """Test that 'local' command shows sessions and converts selected one.""" from click.testing import CliRunner from claude_code_publish import cli + import questionary # Create mock .claude/projects structure projects_dir = tmp_path / ".claude" / "projects" / "test-project" @@ -1197,16 +1106,28 @@ def test_list_local_shows_sessions(self, tmp_path, monkeypatch): # Mock Path.home() to return our tmp_path monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Mock questionary.select to return the session file + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return session_file + + monkeypatch.setattr(questionary, "select", MockSelect) + runner = CliRunner() - result = runner.invoke(cli, ["list-local"]) + result = runner.invoke(cli, ["local"]) assert result.exit_code == 0 - assert "Test local session" in result.output + assert "Loading local sessions" in result.output + assert "Generated" in result.output - def test_no_args_lists_local_sessions(self, tmp_path, monkeypatch): - """Test that running with no arguments lists local sessions.""" + def test_no_args_runs_local_command(self, tmp_path, monkeypatch): + """Test that running with no arguments runs local command.""" from click.testing import CliRunner from claude_code_publish import cli + import questionary # Create mock .claude/projects structure projects_dir = tmp_path / ".claude" / "projects" / "test-project" @@ -1221,8 +1142,53 @@ def test_no_args_lists_local_sessions(self, tmp_path, monkeypatch): # Mock Path.home() to return our tmp_path monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Mock questionary.select to return the session file + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return session_file + + monkeypatch.setattr(questionary, "select", MockSelect) + runner = CliRunner() result = runner.invoke(cli, []) assert result.exit_code == 0 - assert "Test default session" in result.output + assert "Loading local sessions" in result.output + + def test_local_handles_cancelled_selection(self, tmp_path, monkeypatch): + """Test that local command handles cancelled selection gracefully.""" + from click.testing import CliRunner + from claude_code_publish import cli + import questionary + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Mock questionary.select to return None (cancelled) + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return None + + monkeypatch.setattr(questionary, "select", MockSelect) + + runner = CliRunner() + result = runner.invoke(cli, ["local"]) + + assert result.exit_code == 0 + assert "No session selected" in result.output From d08c12e0a93088b724c7db3d70ee0aea256bf760 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 17:29:16 +0000 Subject: [PATCH 19/62] Add -a/--output-auto flag to all commands This flag creates output in a subdirectory named after: - The session ID for web command - The file stem for json and local commands Uses -o as parent directory if specified, otherwise current directory. When -a is used, auto-open browser is disabled. --- src/claude_code_publish/__init__.py | 102 ++++++++++++----- tests/test_generate_html.py | 171 ++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 31 deletions(-) diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 85f0511..9ff5092 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -1082,7 +1082,13 @@ def cli(): "-o", "--output", type=click.Path(), - help="Output directory (default: temp dir, or '.' with -o .)", + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on session filename (uses -o as parent, or current dir).", ) @click.option( "--repo", @@ -1103,14 +1109,14 @@ def cli(): "--open", "open_browser", is_flag=True, - help="Open the generated index.html in your default browser.", + help="Open the generated index.html in your default browser (default if no -o specified).", ) @click.option( "--limit", default=10, help="Maximum number of sessions to show (default: 10)", ) -def local_cmd(output, repo, gist, include_json, open_browser, limit): +def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit): """Select and convert a local Claude Code session to HTML.""" from datetime import datetime @@ -1152,15 +1158,22 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): session_file = selected - # Determine output directory - if (gist or open_browser) and output is None: - output = Path(tempfile.gettempdir()) / session_file.stem + # Determine output directory and whether to open browser + # If no -o specified, use temp dir and open browser by default + auto_open = output is None and not gist and not output_auto + if output_auto: + # Use -o as parent dir (or current dir), with auto-named subdirectory + parent_dir = Path(output) if output else Path(".") + output = parent_dir / session_file.stem elif output is None: - output = Path(tempfile.gettempdir()) / session_file.stem + output = Path(tempfile.gettempdir()) / f"claude-session-{session_file.stem}" output = Path(output) generate_html(session_file, output, github_repo=repo) + # Show output directory + click.echo(f"Output: {output.resolve()}") + # Copy JSONL file to output directory if requested if include_json: output.mkdir(exist_ok=True) @@ -1177,9 +1190,8 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") - click.echo(f"Files: {output}") - if open_browser: + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) @@ -1190,7 +1202,13 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): "-o", "--output", type=click.Path(), - help="Output directory (default: current directory, or temp dir with --gist/--open)", + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on filename (uses -o as parent, or current dir).", ) @click.option( "--repo", @@ -1211,23 +1229,26 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): "--open", "open_browser", is_flag=True, - help="Open the generated index.html in your default browser.", + help="Open the generated index.html in your default browser (default if no -o specified).", ) -def json_cmd(json_file, output, repo, gist, include_json, open_browser): +def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_browser): """Convert a Claude Code session JSON/JSONL file to HTML.""" - # Determine output directory - if (gist or open_browser) and output is None: - # Extract session ID from JSON file for temp directory name - with open(json_file, "r") as f: - data = json.load(f) - session_id = data.get("sessionId", Path(json_file).stem) - output = Path(tempfile.gettempdir()) / session_id + # Determine output directory and whether to open browser + # If no -o specified, use temp dir and open browser by default + auto_open = output is None and not gist and not output_auto + if output_auto: + # Use -o as parent dir (or current dir), with auto-named subdirectory + parent_dir = Path(output) if output else Path(".") + output = parent_dir / Path(json_file).stem elif output is None: - output = "." + output = Path(tempfile.gettempdir()) / f"claude-session-{Path(json_file).stem}" output = Path(output) generate_html(json_file, output, github_repo=repo) + # Show output directory + click.echo(f"Output: {output.resolve()}") + # Copy JSON file to output directory if requested if include_json: output.mkdir(exist_ok=True) @@ -1245,9 +1266,8 @@ def json_cmd(json_file, output, repo, gist, include_json, open_browser): preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") - click.echo(f"Files: {output}") - if open_browser: + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) @@ -1491,7 +1511,13 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): "-o", "--output", type=click.Path(), - help="Output directory (default: creates folder with session ID, or temp dir with --gist/--open)", + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on session ID (uses -o as parent, or current dir).", ) @click.option("--token", help="API access token (auto-detected from keychain on macOS)") @click.option( @@ -1516,10 +1542,18 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): "--open", "open_browser", is_flag=True, - help="Open the generated index.html in your default browser.", + help="Open the generated index.html in your default browser (default if no -o specified).", ) def web_cmd( - session_id, output, token, org_uuid, repo, gist, include_json, open_browser + session_id, + output, + output_auto, + token, + org_uuid, + repo, + gist, + include_json, + open_browser, ): """Select and convert a web session from the Claude API to HTML. @@ -1579,16 +1613,23 @@ def web_cmd( except httpx.RequestError as e: raise click.ClickException(f"Network error: {e}") - # Determine output directory - if (gist or open_browser) and output is None: - output = Path(tempfile.gettempdir()) / session_id + # Determine output directory and whether to open browser + # If no -o specified, use temp dir and open browser by default + auto_open = output is None and not gist and not output_auto + if output_auto: + # Use -o as parent dir (or current dir), with auto-named subdirectory + parent_dir = Path(output) if output else Path(".") + output = parent_dir / session_id elif output is None: - output = session_id + output = Path(tempfile.gettempdir()) / f"claude-session-{session_id}" output = Path(output) click.echo(f"Generating HTML in {output}/...") generate_html_from_session_data(session_data, output, github_repo=repo) + # Show output directory + click.echo(f"Output: {output.resolve()}") + # Save JSON session data if requested if include_json: output.mkdir(exist_ok=True) @@ -1606,9 +1647,8 @@ def web_cmd( preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") - click.echo(f"Files: {output}") - if open_browser: + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 46ff34a..db0aff8 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1192,3 +1192,174 @@ def ask(self): assert result.exit_code == 0 assert "No session selected" in result.output + + +class TestOutputAutoOption: + """Tests for the -a/--output-auto flag.""" + + def test_json_output_auto_creates_subdirectory(self, tmp_path): + """Test that json -a creates output subdirectory named after file stem.""" + from click.testing import CliRunner + from claude_code_publish import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a", "-o", str(tmp_path)], + ) + + assert result.exit_code == 0 + # Output should be in tmp_path/sample_session/ + expected_dir = tmp_path / "sample_session" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_json_output_auto_uses_cwd_when_no_output(self, tmp_path, monkeypatch): + """Test that json -a uses current directory when -o not specified.""" + from click.testing import CliRunner + from claude_code_publish import cli + import os + + fixture_path = Path(__file__).parent / "sample_session.json" + + # Change to tmp_path + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a"], + ) + + assert result.exit_code == 0 + # Output should be in ./sample_session/ + expected_dir = tmp_path / "sample_session" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_json_output_auto_no_browser_open(self, tmp_path, monkeypatch): + """Test that json -a does not auto-open browser.""" + from click.testing import CliRunner + from claude_code_publish import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + # Track webbrowser.open calls + opened_urls = [] + + def mock_open(url): + opened_urls.append(url) + return True + + monkeypatch.setattr("claude_code_publish.webbrowser.open", mock_open) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a", "-o", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert len(opened_urls) == 0 # No browser opened + + def test_local_output_auto_creates_subdirectory(self, tmp_path, monkeypatch): + """Test that local -a creates output subdirectory named after file stem.""" + from click.testing import CliRunner + from claude_code_publish import cli + import questionary + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "my-session-file.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test local session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + output_parent = tmp_path / "output" + output_parent.mkdir() + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Mock questionary.select to return the session file + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return session_file + + monkeypatch.setattr(questionary, "select", MockSelect) + + runner = CliRunner() + result = runner.invoke(cli, ["local", "-a", "-o", str(output_parent)]) + + assert result.exit_code == 0 + # Output should be in output_parent/my-session-file/ + expected_dir = output_parent / "my-session-file" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_web_output_auto_creates_subdirectory(self, httpx_mock, tmp_path): + """Test that web -a creates output subdirectory named after session ID.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Load sample session to mock API response + fixture_path = Path(__file__).parent / "sample_session.json" + with open(fixture_path) as f: + session_data = json.load(f) + + httpx_mock.add_response( + url="https://api.anthropic.com/v1/session_ingress/session/my-web-session-id", + json=session_data, + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "web", + "my-web-session-id", + "--token", + "test-token", + "--org-uuid", + "test-org", + "-a", + "-o", + str(tmp_path), + ], + ) + + assert result.exit_code == 0 + # Output should be in tmp_path/my-web-session-id/ + expected_dir = tmp_path / "my-web-session-id" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_output_auto_with_jsonl_uses_stem(self, tmp_path, monkeypatch): + """Test that -a with JSONL file uses file stem (without .jsonl extension).""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Create a JSONL file + fixture_path = Path(__file__).parent / "sample_session.jsonl" + + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a"], + ) + + assert result.exit_code == 0 + # Output should be in ./sample_session/ (not ./sample_session.jsonl/) + expected_dir = tmp_path / "sample_session" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() From 1ae3016e94daa49ee9433f8f21c9d77ec8895f96 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 25 Dec 2025 13:20:03 -0800 Subject: [PATCH 20/62] Add Jinja2 templates for HTML generation (#2) * Add Jinja2 templates for HTML generation - Add jinja2 as a dependency - Create templates directory with base.html, page.html, and index.html - Refactor generate_html to use Jinja2 template rendering - All 47 tests pass with identical HTML output * Refactor HTML generation to use Jinja2 macros with autoescape - Add comprehensive macros.html with reusable components for all HTML generation (pagination, tool displays, messages, index items, etc.) - Enable Jinja2 autoescape for security - Update base.html, page.html, index.html to use |safe filter for pre-rendered HTML content - Simplify Python render functions to call template macros - Update test snapshots with minor formatting differences Transcript: https://gistpreview.github.io/?ffc01d1c04e47ed7934a58ae04a066d1/index.html --- pyproject.toml | 1 + src/claude_code_publish/__init__.py | 308 ++++++------------ src/claude_code_publish/templates/base.html | 15 + src/claude_code_publish/templates/index.html | 11 + src/claude_code_publish/templates/macros.html | 182 +++++++++++ src/claude_code_publish/templates/page.html | 10 + ...enerateHtml.test_generates_index_html.html | 30 +- ...rateHtml.test_generates_page_001_html.html | 120 +++++-- ...rateHtml.test_generates_page_002_html.html | 30 +- ...estRenderContentBlock.test_text_block.html | 1 + ...enderContentBlock.test_thinking_block.html | 1 + ...RenderFunctions.test_render_bash_tool.html | 1 + ...enderFunctions.test_render_todo_write.html | 1 + ...enderFunctions.test_render_write_tool.html | 2 +- 14 files changed, 456 insertions(+), 257 deletions(-) create mode 100644 src/claude_code_publish/templates/base.html create mode 100644 src/claude_code_publish/templates/index.html create mode 100644 src/claude_code_publish/templates/macros.html create mode 100644 src/claude_code_publish/templates/page.html diff --git a/pyproject.toml b/pyproject.toml index 8ad1e5e..43117e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "click", "click-default-group", "httpx", + "jinja2", "markdown", "questionary", ] diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 9ff5092..9cf0147 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -14,9 +14,26 @@ import click from click_default_group import DefaultGroup import httpx +from jinja2 import Environment, PackageLoader import markdown import questionary +# Set up Jinja2 environment +_jinja_env = Environment( + loader=PackageLoader("claude_code_publish", "templates"), + autoescape=True, +) + +# Load macros template and expose macros +_macros_template = _jinja_env.get_template("macros.html") +_macros = _macros_template.module + + +def get_template(name): + """Get a Jinja2 template by name.""" + return _jinja_env.get_template(name) + + # Regex to match git commit output: [branch hash] message COMMIT_PATTERN = re.compile(r"\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)") @@ -338,34 +355,14 @@ def render_todo_write(tool_input, tool_id): todos = tool_input.get("todos", []) if not todos: return "" - items_html = [] - for todo in todos: - status = todo.get("status", "pending") - content = todo.get("content", "") - if status == "completed": - icon, status_class = "โœ“", "todo-completed" - elif status == "in_progress": - icon, status_class = "โ†’", "todo-in-progress" - else: - icon, status_class = "โ—‹", "todo-pending" - items_html.append( - f'
  • {icon}{html.escape(content)}
  • ' - ) - return f'
    โ˜ฐ Task List
      {"".join(items_html)}
    ' + return _macros.todo_list(todos, 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", "") - # Extract filename from path - filename = file_path.split("/")[-1] if "/" in file_path else file_path - content_preview = html.escape(content) - return f"""
    -
    ๐Ÿ“ Write {html.escape(filename)}
    -
    {html.escape(file_path)}
    -
    {content_preview}
    -
    """ + return _macros.write_tool(file_path, content, tool_id) def render_edit_tool(tool_input, tool_id): @@ -374,34 +371,14 @@ def render_edit_tool(tool_input, tool_id): old_string = tool_input.get("old_string", "") new_string = tool_input.get("new_string", "") replace_all = tool_input.get("replace_all", False) - # Extract filename from path - filename = file_path.split("/")[-1] if "/" in file_path else file_path - replace_note = ( - ' (replace all)' if replace_all else "" - ) - return f"""
    -
    โœ๏ธ Edit {html.escape(filename)}{replace_note}
    -
    {html.escape(file_path)}
    -
    -
    โˆ’
    {html.escape(old_string)}
    -
    +
    {html.escape(new_string)}
    -
    -
    """ + return _macros.edit_tool(file_path, old_string, new_string, replace_all, tool_id) def render_bash_tool(tool_input, tool_id): """Render Bash tool calls with command as plain text.""" command = tool_input.get("command", "") description = tool_input.get("description", "") - desc_html = ( - f'
    {html.escape(description)}
    ' - if description - else "" - ) - return f"""
    -
    $ Bash
    -{desc_html}
    {html.escape(command)}
    -
    """ + return _macros.bash_tool(command, description, tool_id) def render_content_block(block): @@ -409,9 +386,11 @@ def render_content_block(block): return f"

    {html.escape(str(block))}

    " block_type = block.get("type", "") if block_type == "thinking": - return f'
    Thinking
    {render_markdown_text(block.get("thinking", ""))}
    ' + content_html = render_markdown_text(block.get("thinking", "")) + return _macros.thinking(content_html) elif block_type == "text": - return f'
    {render_markdown_text(block.get("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", {}) @@ -425,17 +404,12 @@ def render_content_block(block): if tool_name == "Bash": return render_bash_tool(tool_input, tool_id) description = tool_input.get("description", "") - desc_html = ( - f'
    {html.escape(description)}
    ' - if description - else "" - ) display_input = {k: v for k, v in tool_input.items() if k != "description"} - return f'
    โš™ {html.escape(tool_name)}
    {desc_html}
    {format_json(display_input)}
    ' + input_json = json.dumps(display_input, indent=2, ensure_ascii=False) + return _macros.tool_use(tool_name, description, input_json, tool_id) elif block_type == "tool_result": content = block.get("content", "") is_error = block.get("is_error", False) - error_class = " tool-error" if is_error else "" # Check for git commits and render with styled cards if isinstance(content, str): @@ -452,17 +426,9 @@ def render_content_block(block): 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'
    {commit_hash[:7]} {html.escape(commit_msg)}
    ' - ) + parts.append( + _macros.commit_card(commit_hash, commit_msg, _github_repo) + ) last_end = match.end() # Add any remaining content after last commit @@ -477,7 +443,7 @@ def render_content_block(block): content_html = format_json(content) else: content_html = format_json(content) - return f'
    {content_html}
    ' + return _macros.tool_result(content_html, is_error) else: return format_json(block) @@ -486,8 +452,8 @@ def render_user_message_content(message_data): content = message_data.get("content", "") if isinstance(content, str): if is_json_like(content): - return f'
    {format_json(content)}
    ' - return f'
    {render_markdown_text(content)}
    ' + return _macros.user_content(format_json(content)) + return _macros.user_content(render_markdown_text(content)) elif isinstance(content, list): return "".join(render_content_block(block) for block in content) return f"

    {html.escape(str(content))}

    " @@ -610,7 +576,7 @@ def render_message(log_type, message_json, timestamp): if not content_html.strip(): return "" msg_id = make_msg_id(timestamp) - return f'
    {content_html}
    ' + return _macros.message(role_class, role_label, msg_id, timestamp, content_html) CSS = """ @@ -837,44 +803,12 @@ def create_gist(output_dir, public=False): def generate_pagination_html(current_page, total_pages): - if total_pages <= 1: - return '' - parts = [ - '") - return "\n".join(parts) + 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).""" - if total_pages < 1: - return '' - parts = ['") - return "\n".join(parts) + return _macros.index_pagination(total_pages) def generate_html(json_path, output_dir, github_repo=None): @@ -951,24 +885,15 @@ def generate_html(json_path, output_dir, github_repo=None): messages_html.append(msg_html) is_first = False pagination_html = generate_pagination_html(page_num, total_pages) - page_content = f""" - - - - - Claude Code transcript - page {page_num} - - - -
    -

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

    - {pagination_html} - {''.join(messages_html)} - {pagination_html} -
    - - -""" + page_template = get_template("page.html") + page_content = page_template.render( + css=CSS, + js=JS, + page_num=page_num, + total_pages=total_pages, + pagination_html=pagination_html, + messages_html="".join(messages_html), + ) (output_dir / f"page-{page_num:03d}.html").write_text(page_content) print(f"Generated page-{page_num:03d}.html") @@ -1015,28 +940,23 @@ def generate_html(json_path, output_dir, github_repo=None): stats = analyze_conversation(all_messages) tool_stats_str = format_tool_stats(stats["tool_counts"]) - stats_html = "" - if tool_stats_str or stats["long_texts"]: - long_texts_html = "" - for lt in stats["long_texts"]: - rendered_lt = render_markdown_text(lt) - long_texts_html += f'
    {rendered_lt}
    ' + long_texts_html = "" + for lt in stats["long_texts"]: + rendered_lt = render_markdown_text(lt) + long_texts_html += _macros.index_long_text(rendered_lt) - stats_line = f"{tool_stats_str}" if tool_stats_str else "" - stats_html = ( - f'
    {stats_line}{long_texts_html}
    ' - ) + stats_html = _macros.index_stats(tool_stats_str, long_texts_html) - item_html = f'' + item_html = _macros.index_item( + prompt_num, link, conv["timestamp"], rendered_content, stats_html + ) timeline_items.append((conv["timestamp"], "prompt", item_html)) # Add commits as separate timeline items for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: - if _github_repo: - github_link = f"https://github.com/{_github_repo}/commit/{commit_hash}" - item_html = f"""""" - else: - item_html = f"""
    {commit_hash[:7]}
    {html.escape(commit_msg)}
    """ + item_html = _macros.index_commit( + commit_hash, commit_msg, commit_ts, _github_repo + ) timeline_items.append((commit_ts, "commit", item_html)) # Sort by timestamp @@ -1044,25 +964,18 @@ def generate_html(json_path, output_dir, github_repo=None): index_items = [item[2] for item in timeline_items] index_pagination = generate_index_pagination_html(total_pages) - index_content = f""" - - - - - Claude Code transcript - Index - - - -
    -

    Claude Code transcript

    - {index_pagination} -

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

    - {''.join(index_items)} - {index_pagination} -
    - - -""" + index_template = get_template("index.html") + index_content = index_template.render( + css=CSS, + js=JS, + pagination_html=index_pagination, + prompt_num=prompt_num, + total_messages=total_messages, + total_tool_calls=total_tool_calls, + total_commits=total_commits, + total_pages=total_pages, + index_items_html="".join(index_items), + ) index_path = output_dir / "index.html" index_path.write_text(index_content) print( @@ -1386,24 +1299,15 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): messages_html.append(msg_html) is_first = False pagination_html = generate_pagination_html(page_num, total_pages) - page_content = f""" - - - - - Claude Code transcript - page {page_num} - - - -
    -

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

    - {pagination_html} - {''.join(messages_html)} - {pagination_html} -
    - - -""" + page_template = get_template("page.html") + page_content = page_template.render( + css=CSS, + js=JS, + page_num=page_num, + total_pages=total_pages, + pagination_html=pagination_html, + messages_html="".join(messages_html), + ) (output_dir / f"page-{page_num:03d}.html").write_text(page_content) click.echo(f"Generated page-{page_num:03d}.html") @@ -1450,28 +1354,23 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): stats = analyze_conversation(all_messages) tool_stats_str = format_tool_stats(stats["tool_counts"]) - stats_html = "" - if tool_stats_str or stats["long_texts"]: - long_texts_html = "" - for lt in stats["long_texts"]: - rendered_lt = render_markdown_text(lt) - long_texts_html += f'
    {rendered_lt}
    ' + long_texts_html = "" + for lt in stats["long_texts"]: + rendered_lt = render_markdown_text(lt) + long_texts_html += _macros.index_long_text(rendered_lt) - stats_line = f"{tool_stats_str}" if tool_stats_str else "" - stats_html = ( - f'
    {stats_line}{long_texts_html}
    ' - ) + stats_html = _macros.index_stats(tool_stats_str, long_texts_html) - item_html = f'' + item_html = _macros.index_item( + prompt_num, link, conv["timestamp"], rendered_content, stats_html + ) timeline_items.append((conv["timestamp"], "prompt", item_html)) # Add commits as separate timeline items for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: - if _github_repo: - github_link = f"https://github.com/{_github_repo}/commit/{commit_hash}" - item_html = f"""""" - else: - item_html = f"""
    {commit_hash[:7]}
    {html.escape(commit_msg)}
    """ + item_html = _macros.index_commit( + commit_hash, commit_msg, commit_ts, _github_repo + ) timeline_items.append((commit_ts, "commit", item_html)) # Sort by timestamp @@ -1479,25 +1378,18 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): index_items = [item[2] for item in timeline_items] index_pagination = generate_index_pagination_html(total_pages) - index_content = f""" - - - - - Claude Code transcript - Index - - - -
    -

    Claude Code transcript

    - {index_pagination} -

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

    - {''.join(index_items)} - {index_pagination} -
    - - -""" + index_template = get_template("index.html") + index_content = index_template.render( + css=CSS, + js=JS, + pagination_html=index_pagination, + prompt_num=prompt_num, + total_messages=total_messages, + total_tool_calls=total_tool_calls, + total_commits=total_commits, + total_pages=total_pages, + index_items_html="".join(index_items), + ) index_path = output_dir / "index.html" index_path.write_text(index_content) click.echo( diff --git a/src/claude_code_publish/templates/base.html b/src/claude_code_publish/templates/base.html new file mode 100644 index 0000000..aa833f0 --- /dev/null +++ b/src/claude_code_publish/templates/base.html @@ -0,0 +1,15 @@ + + + + + + {% block title %}Claude Code transcript{% endblock %} + + + +
    +{%- block content %}{% endblock %} +
    + + + \ No newline at end of file diff --git a/src/claude_code_publish/templates/index.html b/src/claude_code_publish/templates/index.html new file mode 100644 index 0000000..e650a7f --- /dev/null +++ b/src/claude_code_publish/templates/index.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Claude Code transcript - Index{% endblock %} + +{% block content %} +

    Claude Code transcript

    + {{ pagination_html|safe }} +

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

    + {{ index_items_html|safe }} + {{ pagination_html|safe }} +{%- endblock %} \ No newline at end of file diff --git a/src/claude_code_publish/templates/macros.html b/src/claude_code_publish/templates/macros.html new file mode 100644 index 0000000..b42a9e5 --- /dev/null +++ b/src/claude_code_publish/templates/macros.html @@ -0,0 +1,182 @@ +{# Pagination for regular pages #} +{% macro pagination(current_page, total_pages) %} +{% if total_pages <= 1 %} + +{%- else %} + +{%- endif %} +{% endmacro %} + +{# Pagination for index page #} +{% macro index_pagination(total_pages) %} +{% if total_pages < 1 %} + +{%- else %} + +{%- endif %} +{% endmacro %} + +{# Todo list #} +{% macro todo_list(todos, tool_id) %} +
    โ˜ฐ Task List
      +{%- for todo in todos -%} +{%- set status = todo.status|default('pending') -%} +{%- set content = todo.content|default('') -%} +{%- if status == 'completed' -%} +{%- set icon = 'โœ“' -%} +{%- set status_class = 'todo-completed' -%} +{%- elif status == 'in_progress' -%} +{%- set icon = 'โ†’' -%} +{%- set status_class = 'todo-in-progress' -%} +{%- else -%} +{%- set icon = 'โ—‹' -%} +{%- set status_class = 'todo-pending' -%} +{%- endif -%} +
    • {{ icon }}{{ content }}
    • +{%- endfor -%} +
    +{%- endmacro %} + +{# Write tool #} +{% macro write_tool(file_path, content, tool_id) %} +{%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%} +
    +
    ๐Ÿ“ Write {{ filename }}
    +
    {{ file_path }}
    +
    {{ content }}
    +
    +{%- endmacro %} + +{# Edit tool #} +{% macro edit_tool(file_path, old_string, new_string, replace_all, tool_id) %} +{%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%} +
    +
    โœ๏ธ Edit {{ filename }}{% if replace_all %} (replace all){% endif %}
    +
    {{ file_path }}
    +
    +
    โˆ’
    {{ old_string }}
    +
    +
    {{ new_string }}
    +
    +
    +{%- endmacro %} + +{# Bash tool #} +{% macro bash_tool(command, description, tool_id) %} +
    +
    $ Bash
    +{%- if description %} +
    {{ description }}
    +{%- endif -%} +
    {{ command }}
    +
    +{%- endmacro %} + +{# Generic tool use - input_json is pre-formatted so needs |safe #} +{% macro tool_use(tool_name, description, input_json, tool_id) %} +
    โš™ {{ tool_name }}
    +{%- if description -%} +
    {{ description }}
    +{%- endif -%} +
    {{ input_json }}
    +{%- endmacro %} + +{# Tool result - content_html is pre-rendered so needs |safe #} +{% macro tool_result(content_html, is_error) %} +{%- set error_class = ' tool-error' if is_error else '' -%} +
    {{ content_html|safe }}
    +{%- endmacro %} + +{# Thinking block - content_html is pre-rendered markdown so needs |safe #} +{% macro thinking(content_html) %} +
    Thinking
    {{ content_html|safe }}
    +{%- endmacro %} + +{# Assistant text - content_html is pre-rendered markdown so needs |safe #} +{% macro assistant_text(content_html) %} +
    {{ content_html|safe }}
    +{%- endmacro %} + +{# User content - content_html is pre-rendered so needs |safe #} +{% macro user_content(content_html) %} +
    {{ content_html|safe }}
    +{%- endmacro %} + +{# Commit card (in tool results) #} +{% macro commit_card(commit_hash, commit_msg, github_repo) %} +{%- if github_repo -%} +{%- set github_link = 'https://github.com/' ~ github_repo ~ '/commit/' ~ commit_hash -%} + +{%- else -%} +
    {{ commit_hash[:7] }} {{ commit_msg }}
    +{%- endif %} +{%- endmacro %} + +{# Message wrapper - content_html is pre-rendered so needs |safe #} +{% macro message(role_class, role_label, msg_id, timestamp, content_html) %} +
    {{ role_label }}
    {{ content_html|safe }}
    +{%- endmacro %} + +{# Continuation wrapper - content_html is pre-rendered so needs |safe #} +{% macro continuation(content_html) %} +
    Session continuation summary{{ content_html|safe }}
    +{%- endmacro %} + +{# Index item (prompt) - rendered_content and stats_html are pre-rendered so need |safe #} +{% macro index_item(prompt_num, link, timestamp, rendered_content, stats_html) %} + +{%- endmacro %} + +{# Index commit #} +{% macro index_commit(commit_hash, commit_msg, timestamp, github_repo) %} +{%- if github_repo -%} +{%- set github_link = 'https://github.com/' ~ github_repo ~ '/commit/' ~ commit_hash -%} + +{%- else -%} +
    {{ commit_hash[:7] }}
    {{ commit_msg }}
    +{%- endif %} +{%- endmacro %} + +{# Index stats - tool_stats_str and long_texts_html are pre-rendered so need |safe #} +{% macro index_stats(tool_stats_str, long_texts_html) %} +{%- if tool_stats_str or long_texts_html -%} +
    +{%- if tool_stats_str -%}{{ tool_stats_str }}{%- endif -%} +{{ long_texts_html|safe }} +
    +{%- endif %} +{%- endmacro %} + +{# Long text in index - rendered_content is pre-rendered markdown so needs |safe #} +{% macro index_long_text(rendered_content) %} +
    {{ rendered_content|safe }}
    +{%- endmacro %} diff --git a/src/claude_code_publish/templates/page.html b/src/claude_code_publish/templates/page.html new file mode 100644 index 0000000..eaa4e5f --- /dev/null +++ b/src/claude_code_publish/templates/page.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block title %}Claude Code transcript - page {{ page_num }}{% endblock %} + +{% block content %} +

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

    + {{ pagination_html|safe }} + {{ messages_html|safe }} + {{ pagination_html|safe }} +{%- endblock %} \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 6c27358..812effd 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -126,22 +126,38 @@

    Claude Code transcript

    - \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index cdc794b..a7fc7c3 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -89,6 +89,11 @@ .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; } +.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; } +.copy-btn:hover { background: white; color: var(--text-color); } +.copy-btn.copied { background: #c8e6c9; color: #2e7d32; } +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: 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); } @@ -320,6 +325,33 @@

    Claude C }); } }); +// 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; + // 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(); + navigator.clipboard.writeText(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); + }); + }); + el.appendChild(copyBtn); +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 2d46a78..4033573 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -89,6 +89,11 @@ .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; } +.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; } +.copy-btn:hover { background: white; color: var(--text-color); } +.copy-btn.copied { background: #c8e6c9; color: #2e7d32; } +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: 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); } @@ -217,6 +222,33 @@

    Claude C }); } }); +// 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; + // 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(); + navigator.clipboard.writeText(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); + }); + }); + el.appendChild(copyBtn); +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index 318283c..01db58b 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -89,6 +89,11 @@ .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; } +.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; } +.copy-btn:hover { background: white; color: var(--text-color); } +.copy-btn.copied { background: #c8e6c9; color: #2e7d32; } +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: 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); } @@ -500,6 +505,33 @@

    Claude Code transcript

    }); } }); +// 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; + // 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(); + navigator.clipboard.writeText(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); + }); + }); + el.appendChild(copyBtn); +}); \ No newline at end of file diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 5fab736..f6fdd1d 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1570,3 +1570,41 @@ def test_search_total_pages_available(self, output_dir): # Total pages should be embedded for JS to know how many pages to fetch assert "totalPages" in index_html or "total_pages" in index_html + + +class TestCopyButtonFeature: + """Tests for copy button functionality.""" + + def test_copy_button_css_present(self, output_dir): + """Test that copy button CSS styles are present.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # CSS should style the copy button + assert ".copy-btn" in page_html + + def test_copy_button_javascript_present(self, output_dir): + """Test that copy button JavaScript functionality is present.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # JavaScript should handle clipboard API + assert "clipboard" in page_html.lower() or "navigator.clipboard" in page_html + + def test_expand_button_has_clear_state(self, output_dir): + """Test that expand button has clear expanded/collapsed indicators.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # Should have indicators for expand/collapse state (chevrons or similar) + assert ( + "โ–ผ" in page_html + or "chevron" in page_html.lower() + or "expand" in page_html.lower() + ) From ee13ef8abcc6b324968d12d1ed9523ab49e76749 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 00:57:57 -0500 Subject: [PATCH 46/62] Add syntax highlighting with Pygments for code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Pygments dependency for syntax highlighting - Implement highlight_code() function that detects language from file extension - Apply highlighting to Write and Edit tool content - Add Monokai-inspired dark theme CSS for highlighted code - Supports Python, JavaScript, HTML, CSS, JSON, and many other languages - Falls back gracefully to plain text for unrecognized file types This significantly improves code readability in transcript outputs by providing proper syntax coloring for different programming languages. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 1 + src/claude_code_transcripts/__init__.py | 73 ++++++++++++++++++- .../templates/macros.html | 10 +-- ...enerateHtml.test_generates_index_html.html | 29 ++++++++ ...rateHtml.test_generates_page_001_html.html | 51 ++++++++++--- ...rateHtml.test_generates_page_002_html.html | 39 ++++++++-- ...SessionFile.test_jsonl_generates_html.html | 29 ++++++++ ...RenderFunctions.test_render_edit_tool.html | 6 +- ...ons.test_render_edit_tool_replace_all.html | 6 +- ...enderFunctions.test_render_write_tool.html | 4 +- tests/test_generate_html.py | 41 +++++++++++ 11 files changed, 261 insertions(+), 28 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 967c79f..94c4d3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "httpx", "jinja2", "markdown", + "pygments>=2.17.0", "questionary", ] diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 7a8c134..32f4b97 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -17,6 +17,10 @@ import httpx from jinja2 import Environment, PackageLoader import markdown +from pygments import highlight +from pygments.lexers import get_lexer_for_filename, get_lexer_by_name, TextLexer +from pygments.formatters import HtmlFormatter +from pygments.util import ClassNotFound import questionary # Set up Jinja2 environment @@ -122,6 +126,35 @@ def render_content_block_array(blocks): return "".join(parts) if parts else None +def highlight_code(code, filename=None, language=None): + """Apply syntax highlighting to code using Pygments. + + Args: + code: The source code to highlight. + filename: Optional filename to detect language from extension. + language: Optional explicit language name. + + Returns: + HTML string with syntax highlighting, or escaped plain text if highlighting fails. + """ + if not code: + return "" + + try: + if language: + lexer = get_lexer_by_name(language) + elif filename: + lexer = get_lexer_for_filename(filename) + else: + lexer = TextLexer() + except ClassNotFound: + lexer = TextLexer() + + formatter = HtmlFormatter(nowrap=True, cssclass="highlight") + highlighted = highlight(code, lexer, formatter) + return highlighted + + def extract_text_from_content(content): """Extract plain text from message content. @@ -728,7 +761,9 @@ 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", "") - return _macros.write_tool(file_path, content, tool_id) + # Apply syntax highlighting based on file extension + highlighted_content = highlight_code(content, filename=file_path) + return _macros.write_tool(file_path, highlighted_content, tool_id) def render_edit_tool(tool_input, tool_id): @@ -737,7 +772,12 @@ def render_edit_tool(tool_input, tool_id): old_string = tool_input.get("old_string", "") new_string = tool_input.get("new_string", "") replace_all = tool_input.get("replace_all", False) - return _macros.edit_tool(file_path, old_string, new_string, replace_all, tool_id) + # 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) + return _macros.edit_tool( + file_path, highlighted_old, highlighted_new, replace_all, tool_id + ) def render_bash_tool(tool_input, tool_id): @@ -1037,8 +1077,37 @@ def render_message(log_type, message_json, timestamp): .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; } +pre.highlight { 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; } +.highlight .hll { background-color: #49483e } +.highlight .c { color: #75715e } /* Comment */ +.highlight .err { color: #f92672 } /* Error */ +.highlight .k { color: #66d9ef } /* Keyword */ +.highlight .l { color: #ae81ff } /* Literal */ +.highlight .n { color: #e0e0e0 } /* Name */ +.highlight .o { color: #f92672 } /* Operator */ +.highlight .p { color: #e0e0e0 } /* Punctuation */ +.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #75715e } /* Comments */ +.highlight .gd { color: #f92672 } /* Generic.Deleted */ +.highlight .gi { color: #a6e22e } /* Generic.Inserted */ +.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #66d9ef } /* Keywords */ +.highlight .ld { color: #e6db74 } /* Literal.Date */ +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #ae81ff } /* Numbers */ +.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: #e6db74 } /* Strings */ +.highlight .na { color: #a6e22e } /* Name.Attribute */ +.highlight .nb { color: #e0e0e0 } /* Name.Builtin */ +.highlight .nc { color: #a6e22e } /* Name.Class */ +.highlight .no { color: #66d9ef } /* Name.Constant */ +.highlight .nd { color: #a6e22e } /* Name.Decorator */ +.highlight .ne { color: #a6e22e } /* Name.Exception */ +.highlight .nf { color: #a6e22e } /* Name.Function */ +.highlight .nl { color: #e0e0e0 } /* Name.Label */ +.highlight .nn { color: #e0e0e0 } /* Name.Namespace */ +.highlight .nt { color: #f92672 } /* Name.Tag */ +.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #e0e0e0 } /* Variables */ +.highlight .ow { color: #f92672 } /* Operator.Word */ +.highlight .w { color: #e0e0e0 } /* Text.Whitespace */ .user-content { margin: 0; } .truncatable { position: relative; } .truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index 66866e5..e23091e 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -67,25 +67,25 @@

    {%- endmacro %} -{# Write tool #} +{# Write tool - content is pre-highlighted so needs |safe #} {% macro write_tool(file_path, content, tool_id) %} {%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%}
    ๐Ÿ“ Write {{ filename }}
    {{ file_path }}
    -
    {{ content }}
    +
    {{ content|safe }}
    {%- endmacro %} -{# Edit tool #} +{# Edit tool - old/new strings are pre-highlighted so need |safe #} {% macro edit_tool(file_path, old_string, new_string, replace_all, tool_id) %} {%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%}
    โœ๏ธ Edit {{ filename }}{% if replace_all %} (replace all){% endif %}
    {{ file_path }}
    -
    โˆ’
    {{ old_string }}
    -
    +
    {{ new_string }}
    +
    โˆ’
    {{ old_string|safe }}
    +
    +
    {{ new_string|safe }}
    {%- endmacro %} diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index c79c0be..3b6ff06 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -76,8 +76,37 @@ .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; } +pre.highlight { 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; } +.highlight .hll { background-color: #49483e } +.highlight .c { color: #75715e } /* Comment */ +.highlight .err { color: #f92672 } /* Error */ +.highlight .k { color: #66d9ef } /* Keyword */ +.highlight .l { color: #ae81ff } /* Literal */ +.highlight .n { color: #e0e0e0 } /* Name */ +.highlight .o { color: #f92672 } /* Operator */ +.highlight .p { color: #e0e0e0 } /* Punctuation */ +.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #75715e } /* Comments */ +.highlight .gd { color: #f92672 } /* Generic.Deleted */ +.highlight .gi { color: #a6e22e } /* Generic.Inserted */ +.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #66d9ef } /* Keywords */ +.highlight .ld { color: #e6db74 } /* Literal.Date */ +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #ae81ff } /* Numbers */ +.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: #e6db74 } /* Strings */ +.highlight .na { color: #a6e22e } /* Name.Attribute */ +.highlight .nb { color: #e0e0e0 } /* Name.Builtin */ +.highlight .nc { color: #a6e22e } /* Name.Class */ +.highlight .no { color: #66d9ef } /* Name.Constant */ +.highlight .nd { color: #a6e22e } /* Name.Decorator */ +.highlight .ne { color: #a6e22e } /* Name.Exception */ +.highlight .nf { color: #a6e22e } /* Name.Function */ +.highlight .nl { color: #e0e0e0 } /* Name.Label */ +.highlight .nn { color: #e0e0e0 } /* Name.Namespace */ +.highlight .nt { color: #f92672 } /* Name.Tag */ +.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #e0e0e0 } /* Variables */ +.highlight .ow { color: #f92672 } /* Operator.Word */ +.highlight .w { color: #e0e0e0 } /* Text.Whitespace */ .user-content { margin: 0; } .truncatable { position: relative; } .truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index a7fc7c3..d2a2cc4 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -76,8 +76,37 @@ .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; } +pre.highlight { 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; } +.highlight .hll { background-color: #49483e } +.highlight .c { color: #75715e } /* Comment */ +.highlight .err { color: #f92672 } /* Error */ +.highlight .k { color: #66d9ef } /* Keyword */ +.highlight .l { color: #ae81ff } /* Literal */ +.highlight .n { color: #e0e0e0 } /* Name */ +.highlight .o { color: #f92672 } /* Operator */ +.highlight .p { color: #e0e0e0 } /* Punctuation */ +.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #75715e } /* Comments */ +.highlight .gd { color: #f92672 } /* Generic.Deleted */ +.highlight .gi { color: #a6e22e } /* Generic.Inserted */ +.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #66d9ef } /* Keywords */ +.highlight .ld { color: #e6db74 } /* Literal.Date */ +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #ae81ff } /* Numbers */ +.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: #e6db74 } /* Strings */ +.highlight .na { color: #a6e22e } /* Name.Attribute */ +.highlight .nb { color: #e0e0e0 } /* Name.Builtin */ +.highlight .nc { color: #a6e22e } /* Name.Class */ +.highlight .no { color: #66d9ef } /* Name.Constant */ +.highlight .nd { color: #a6e22e } /* Name.Decorator */ +.highlight .ne { color: #a6e22e } /* Name.Exception */ +.highlight .nf { color: #a6e22e } /* Name.Function */ +.highlight .nl { color: #e0e0e0 } /* Name.Label */ +.highlight .nn { color: #e0e0e0 } /* Name.Namespace */ +.highlight .nt { color: #f92672 } /* Name.Tag */ +.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #e0e0e0 } /* Variables */ +.highlight .ow { color: #f92672 } /* Operator.Word */ +.highlight .w { color: #e0e0e0 } /* Text.Whitespace */ .user-content { margin: 0; } .truncatable { position: relative; } .truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } @@ -172,9 +201,9 @@

    Claude C

    I'll create a simple Python function for you. Let me write it now.

    ๐Ÿ“ Write math_utils.py
    /project/math_utils.py
    -
    def add(a: int, b: int) -> int:
    -    """Add two numbers together."""
    -    return a + b
    +
    def add(a: int, b: int) -> int:
    +    """Add two numbers together."""
    +    return a + b
     
    File written successfully
    @@ -225,14 +254,14 @@

    Claude C
    โœ๏ธ Edit math_utils.py
    /project/math_utils.py
    -
    โˆ’
        return a + b
    +
    โˆ’
        return a + b
     
    -
    +
        return a + b
    +
    +
        return a + b
     
     
    -def subtract(a: int, b: int) -> int:
    -    """Subtract b from a."""
    -    return a - b
    +def subtract(a: int, b: int) -> int:
    +    """Subtract b from a."""
    +    return a - b
     
    @@ -270,8 +299,10 @@

    Claude C
    โœ๏ธ Edit test_math.py (replace all)
    /project/tests/test_math.py
    -
    โˆ’
    assert subtract(10, 5) == None
    -
    +
    assert subtract(10, 5) == 5
    +
    โˆ’
    assert subtract(10, 5) == None
    +
    +
    +
    assert subtract(10, 5) == 5
    +
    File edited successfully
    diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 4033573..97c158d 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -76,8 +76,37 @@ .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; } +pre.highlight { 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; } +.highlight .hll { background-color: #49483e } +.highlight .c { color: #75715e } /* Comment */ +.highlight .err { color: #f92672 } /* Error */ +.highlight .k { color: #66d9ef } /* Keyword */ +.highlight .l { color: #ae81ff } /* Literal */ +.highlight .n { color: #e0e0e0 } /* Name */ +.highlight .o { color: #f92672 } /* Operator */ +.highlight .p { color: #e0e0e0 } /* Punctuation */ +.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #75715e } /* Comments */ +.highlight .gd { color: #f92672 } /* Generic.Deleted */ +.highlight .gi { color: #a6e22e } /* Generic.Inserted */ +.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #66d9ef } /* Keywords */ +.highlight .ld { color: #e6db74 } /* Literal.Date */ +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #ae81ff } /* Numbers */ +.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: #e6db74 } /* Strings */ +.highlight .na { color: #a6e22e } /* Name.Attribute */ +.highlight .nb { color: #e0e0e0 } /* Name.Builtin */ +.highlight .nc { color: #a6e22e } /* Name.Class */ +.highlight .no { color: #66d9ef } /* Name.Constant */ +.highlight .nd { color: #a6e22e } /* Name.Decorator */ +.highlight .ne { color: #a6e22e } /* Name.Exception */ +.highlight .nf { color: #a6e22e } /* Name.Function */ +.highlight .nl { color: #e0e0e0 } /* Name.Label */ +.highlight .nn { color: #e0e0e0 } /* Name.Namespace */ +.highlight .nt { color: #f92672 } /* Name.Tag */ +.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #e0e0e0 } /* Variables */ +.highlight .ow { color: #f92672 } /* Operator.Word */ +.highlight .w { color: #e0e0e0 } /* Text.Whitespace */ .user-content { margin: 0; } .truncatable { position: relative; } .truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } @@ -168,14 +197,14 @@

    Claude C
    โœ๏ธ Edit math_utils.py
    /project/math_utils.py
    -
    โˆ’
        return a - b
    +
    โˆ’
        return a - b
     
    -
    +
        return a - b
    +
    +
        return a - b
     
     
    -def multiply(a: int, b: int) -> int:
    -    """Multiply two numbers."""
    -    return a * b
    +def multiply(a: int, b: int) -> int:
    +    """Multiply two numbers."""
    +    return a * b
     
    diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index 01db58b..04e74f9 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -76,8 +76,37 @@ .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; } +pre.highlight { 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; } +.highlight .hll { background-color: #49483e } +.highlight .c { color: #75715e } /* Comment */ +.highlight .err { color: #f92672 } /* Error */ +.highlight .k { color: #66d9ef } /* Keyword */ +.highlight .l { color: #ae81ff } /* Literal */ +.highlight .n { color: #e0e0e0 } /* Name */ +.highlight .o { color: #f92672 } /* Operator */ +.highlight .p { color: #e0e0e0 } /* Punctuation */ +.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #75715e } /* Comments */ +.highlight .gd { color: #f92672 } /* Generic.Deleted */ +.highlight .gi { color: #a6e22e } /* Generic.Inserted */ +.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #66d9ef } /* Keywords */ +.highlight .ld { color: #e6db74 } /* Literal.Date */ +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #ae81ff } /* Numbers */ +.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: #e6db74 } /* Strings */ +.highlight .na { color: #a6e22e } /* Name.Attribute */ +.highlight .nb { color: #e0e0e0 } /* Name.Builtin */ +.highlight .nc { color: #a6e22e } /* Name.Class */ +.highlight .no { color: #66d9ef } /* Name.Constant */ +.highlight .nd { color: #a6e22e } /* Name.Decorator */ +.highlight .ne { color: #a6e22e } /* Name.Exception */ +.highlight .nf { color: #a6e22e } /* Name.Function */ +.highlight .nl { color: #e0e0e0 } /* Name.Label */ +.highlight .nn { color: #e0e0e0 } /* Name.Namespace */ +.highlight .nt { color: #f92672 } /* Name.Tag */ +.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #e0e0e0 } /* Variables */ +.highlight .ow { color: #f92672 } /* Operator.Word */ +.highlight .w { color: #e0e0e0 } /* Text.Whitespace */ .user-content { margin: 0; } .truncatable { position: relative; } .truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html index 7eef19b..010c9d3 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html @@ -2,7 +2,9 @@
    โœ๏ธ Edit file.py
    /project/file.py
    -
    โˆ’
    old code here
    -
    +
    new code here
    +
    โˆ’
    old code here
    +
    +
    +
    new code here
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html index ad332b0..9ac42ff 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html @@ -2,7 +2,9 @@
    โœ๏ธ Edit file.py (replace all)
    /project/file.py
    -
    โˆ’
    old
    -
    +
    new
    +
    โˆ’
    old
    +
    +
    +
    new
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html index bef95a9..8835c8a 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html @@ -1,7 +1,7 @@
    ๐Ÿ“ Write main.py
    /project/src/main.py
    -
    def hello():
    -    print('hello world')
    +
    def hello():
    +    print('hello world')
     
    \ No newline at end of file diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index f6fdd1d..ec5cade 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1572,6 +1572,47 @@ def test_search_total_pages_available(self, output_dir): assert "totalPages" in index_html or "total_pages" in index_html +class TestSyntaxHighlighting: + """Tests for syntax highlighting functionality.""" + + def test_python_code_has_syntax_highlighting(self): + """Test that Python code in Write tool gets syntax highlighted.""" + from claude_code_transcripts import render_write_tool + + result = render_write_tool( + { + "file_path": "/path/to/test.py", + "content": "def hello():\n return 'world'", + }, + "tool-1", + ) + # Should have syntax highlighting classes from Pygments + assert "highlight" in result or "class=" in result + + def test_javascript_code_has_syntax_highlighting(self): + """Test that JavaScript code gets syntax highlighted.""" + from claude_code_transcripts import render_write_tool + + result = render_write_tool( + { + "file_path": "/path/to/test.js", + "content": "function hello() {\n return 'world';\n}", + }, + "tool-2", + ) + # Should have syntax highlighting + assert "highlight" in result or "class=" in result + + def test_unknown_extension_still_renders(self): + """Test that files with unknown extensions still render properly.""" + from claude_code_transcripts import render_write_tool + + result = render_write_tool( + {"file_path": "/path/to/test.xyz", "content": "some content"}, "tool-3" + ) + assert "some content" in result + + class TestCopyButtonFeature: """Tests for copy button functionality.""" From 6ffbff31670e83bf83dbbb1c7a1f492138dcb4bd Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 01:47:59 -0500 Subject: [PATCH 47/62] Update TASKS.md and AGENTS.md with comprehensive documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2 deliverables from subagent analysis: ## Task Grading (0.00-10.00) - B.5 ANSI Sanitization: 6.75/10 - Works for common cases but regex misses ~30% of ANSI sequences (cursor control, OSC sequences) - B.4 Content-Block Arrays: 6.75/10 - Handles text/thinking but not images or tool_use blocks - A.1 Copy Buttons: 6.75/10 - Functional but lacks accessibility (no ARIA labels, keyboard support, clipboard API fallback) - B.2 Syntax Highlighting: 9.25/10 - Excellent implementation with 500+ language support and graceful error handling ## TASKS.md Updates - Added detailed grading with justifications - Added known limitations for each completed task - Added test coverage gaps to address - Added technical specifications (file architecture, CSS/JS guidelines) - Added task dependency graph - Added implementation details for pending phases - Added documentation gaps identified ## AGENTS.md Updates - Added Quick Start section with setup commands - Added Project Structure overview - Added comprehensive testing commands - Added snapshot testing instructions - Added debugging tips - Added architecture notes ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a463e3e..1965014 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,15 +1,161 @@ -Uses uv. Run tests like this: +# Development Guide - uv run pytest +This guide covers everything needed to contribute to claude-code-transcripts. -Run the development version of the tool like this: +## Quick Start - uv run claude-code-transcripts --help +```bash +# Clone and setup +git clone https://github.com/simonw/claude-code-transcripts.git +cd claude-code-transcripts -Always practice TDD: write a faliing test, watch it fail, then make it pass. +# Install uv if not already installed +# See: https://docs.astral.sh/uv/ -Commit early and often. Commits should bundle the test, implementation, and documentation changes together. +# Install dependencies +uv sync --group dev -Run Black to format code before you commit: +# Run tests +uv run pytest - uv run black . +# 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 From 5169d88b54271b6cd5d71117be824c996f3377a4 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 02:38:25 -0500 Subject: [PATCH 48/62] Render content-block arrays fully Use render_content_block for array items Add tests for image and tool_use arrays --- src/claude_code_transcripts/__init__.py | 16 +-------- ....test_tool_result_content_block_array.html | 5 +-- ...result_content_block_array_with_image.html | 2 ++ ...ult_content_block_array_with_tool_use.html | 5 +++ tests/test_generate_html.py | 33 +++++++++++++++++++ 5 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html create mode 100644 tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 32f4b97..38878a5 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -108,21 +108,7 @@ def render_content_block_array(blocks): """ parts = [] for block in blocks: - if not isinstance(block, dict): - continue - block_type = block.get("type", "") - if block_type == "text": - text = block.get("text", "") - # Render as markdown - parts.append(render_markdown_text(text)) - elif block_type == "thinking": - thinking = block.get("thinking", "") - parts.append(render_markdown_text(thinking)) - else: - # For other types, just show as formatted text - text = block.get("text", block.get("content", "")) - if text: - parts.append(f"
    {html.escape(str(text))}
    ") + parts.append(render_content_block(block)) return "".join(parts) if parts else None diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html index 39e210b..28f5587 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html @@ -1,3 +1,4 @@ -

    Here is the file content:

    +
    +

    Here is the file content:

    Line 1 -Line 2

    \ No newline at end of file +Line 2

    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html new file mode 100644 index 0000000..22761d2 --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html @@ -0,0 +1,2 @@ +
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html new file mode 100644 index 0000000..5299d2b --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html @@ -0,0 +1,5 @@ +
    +
    +
    $ Bash
    +
    List files
    ls -la
    +
    \ No newline at end of file diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index ec5cade..bfaf26e 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -335,6 +335,39 @@ def test_tool_result_content_block_array(self, snapshot_html): assert '"type": "text"' not in result assert result == snapshot_html + def test_tool_result_content_block_array_with_image(self, snapshot_html): + """Test that image blocks inside tool_result arrays render correctly.""" + block = { + "type": "tool_result", + "content": ( + '[{"type": "image", "source": {"type": "base64",' + ' "media_type": "image/gif", "data": "R0lGODlhAQABAIAAAAUEBA=="}}]' + ), + "is_error": False, + } + result = render_content_block(block) + assert 'src="data:image/gif;base64,' in result + assert "image-block" in result + assert '"type": "image"' not in result + assert result == snapshot_html + + def test_tool_result_content_block_array_with_tool_use(self, snapshot_html): + """Test that tool_use blocks inside tool_result arrays render correctly.""" + block = { + "type": "tool_result", + "content": ( + '[{"type": "tool_use", "id": "toolu_123", "name": "Bash",' + ' "input": {"command": "ls -la", "description": "List files"}}]' + ), + "is_error": False, + } + result = render_content_block(block) + assert "tool-use" in result + assert "bash-tool" in result + assert "List files" in result + assert '"type": "tool_use"' not in result + assert result == snapshot_html + class TestAnalyzeConversation: """Tests for conversation analysis.""" From 8981b6313bcf0df5b7f4c6bc8e626add0cd88c7a Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 02:57:23 -0500 Subject: [PATCH 49/62] Pair tool_use with tool_result Group tool calls with matching results by tool_use_id Add tool-pair wrapper and page snapshot updates --- src/claude_code_transcripts/__init__.py | 142 +++++++++++++++++- .../templates/macros.html | 5 + ...rateHtml.test_generates_page_001_html.html | 60 ++++---- ...rateHtml.test_generates_page_002_html.html | 8 +- tests/test_generate_html.py | 8 + 5 files changed, 189 insertions(+), 34 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 8729fad..5b615d3 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -757,6 +757,66 @@ def render_user_message_content(message_data): 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 render_assistant_message_with_tool_pairs( + message_data, tool_result_lookup, paired_tool_ids +): + content = message_data.get("content", []) + if not isinstance(content, list): + return f"

    {html.escape(str(content))}

    " + parts = [] + for block in content: + if not isinstance(block, dict): + parts.append(f"

    {html.escape(str(block))}

    ") + continue + if block.get("type") == "tool_use": + tool_id = block.get("id", "") + 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) + parts.append(_macros.tool_pair(tool_use_html, tool_result_html)) + continue + parts.append(render_content_block(block)) + return "".join(parts) + + def render_assistant_message(message_data): content = message_data.get("content", []) if not isinstance(content, list): @@ -877,6 +937,34 @@ def render_message(log_type, message_json, timestamp): return _macros.message(role_class, role_label, msg_id, timestamp, content_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) + return _macros.message(role_class, role_label, msg_id, timestamp, content_html) + + CSS = """ :root { --bg-color: #f5f5f5; --card-bg: #ffffff; --user-bg: #e3f2fd; --user-border: #1976d2; --assistant-bg: #f5f5f5; --assistant-border: #9e9e9e; --thinking-bg: #fff8e1; --thinking-border: #ffc107; --thinking-text: #666; --tool-bg: #f3e5f5; --tool-border: #9c27b0; --tool-result-bg: #e8f5e9; --tool-error-bg: #ffebee; --text-color: #212121; --text-muted: #757575; --code-bg: #263238; --code-text: #aed581; } * { box-sizing: border-box; } @@ -913,6 +1001,8 @@ def render_message(log_type, message_json, timestamp): .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); } +.tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } +.tool-pair .tool-use, .tool-pair .tool-result { margin: 8px 0; } .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; } @@ -1219,8 +1309,32 @@ def generate_html(json_path, output_dir, github_repo=None): messages_html = [] for conv in page_convs: is_first = True + parsed_messages = [] for log_type, message_json, timestamp in conv["messages"]: - msg_html = render_message(log_type, message_json, timestamp) + 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"): @@ -1689,8 +1803,32 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): messages_html = [] for conv in page_convs: is_first = True + parsed_messages = [] for log_type, message_json, timestamp in conv["messages"]: - msg_html = render_message(log_type, message_json, timestamp) + 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"): diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index 66866e5..8fad050 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -116,6 +116,11 @@
    {{ content_html|safe }}
    {%- endmacro %} +{# Tool pair wrapper - tool_use_html/tool_result_html are pre-rendered #} +{% macro tool_pair(tool_use_html, tool_result_html) %} +
    {{ tool_use_html|safe }}{{ tool_result_html|safe }}
    +{%- endmacro %} + {# Thinking block - content_html is pre-rendered markdown so needs |safe #} {% macro thinking(content_html) %}
    Thinking
    {{ content_html|safe }}
    diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index cdc794b..7860594 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -40,6 +40,8 @@ .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); } +.tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } +.tool-pair .tool-use, .tool-pair .tool-result { margin: 8px 0; } .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; } @@ -164,59 +166,60 @@

    Claude C 1. Create the function 2. Add a basic test

    This is straightforward.

    -

    I'll create a simple Python function for you. Let me write it now.

    +

    I'll create a simple Python function for you. Let me write it now.

    +
    ๐Ÿ“ Write math_utils.py
    /project/math_utils.py
    def add(a: int, b: int) -> int:
         """Add two numbers together."""
         return a + b
     
    -
    -
    File written successfully
    +
    File written successfully

    I've created the add function. Now let me run the tests.

    +
    $ Bash
    Run pytest on tests directory
    python -m pytest tests/
    -
    -
    ===== test session starts =====
    +
    ===== test session starts =====
     collected 2 items
     
     tests/test_math.py ..  [100%]
     
    -===== 2 passed in 0.05s =====
    +===== 2 passed in 0.05s =====

    All tests pass. Let me update the todo list and make a commit.

    -
    โ˜ฐ Task List
    • โœ“Create add function
    • โœ“Write tests
    • โœ“Run tests
    • โ†’Commit changes
    • โ—‹Push to remote
    -
    Todos updated
    +
    +
    โ˜ฐ Task List
    • โœ“Create add function
    • โœ“Write tests
    • โœ“Run tests
    • โ†’Commit changes
    • โ—‹Push to remote
    Todos updated
    +
    $ Bash
    Commit changes
    git add . && git commit -m 'Add math_utils with add function'
    -
    -
    1 file changed, 5 insertions(+)
    +
    1 file changed, 5 insertions(+)

    Committed! Now pushing to remote.

    +
    $ Bash
    Push to remote
    git push -u origin main
    -
    -
    remote: 
    +
    remote: 
     remote: Create a pull request for 'main' on GitHub by visiting:
     remote:      https://github.com/example/project/pull/new/main
     remote: 
     To github.com:example/project.git
    -   def5678..abc1234  main -> main
    + def5678..abc1234 main -> main

    Now edit the file to add a subtract function

    +
    โš™ Glob
    {
       "pattern": "**/*.py",
       "path": "/project"
    -}
    -
    /project/math_utils.py
    -/project/tests/test_math.py
    -
    +}
    /project/math_utils.py
    +/project/tests/test_math.py
    +
    +
    โœ๏ธ Edit math_utils.py
    /project/math_utils.py
    @@ -230,26 +233,25 @@

    Claude C return a - b

    -
    -
    File edited successfully
    +
    File edited successfully

    Added the subtract function. Let me verify with grep.

    +
    โš™ Grep
    {
       "pattern": "def subtract",
       "path": "/project",
       "output_mode": "content"
    -}
    -
    /project/math_utils.py:6:def subtract(a: int, b: int) -> int:
    +}
    /project/math_utils.py:6:def subtract(a: int, b: int) -> int:

    Run the tests again

    +
    $ Bash
    Run tests with verbose output
    python -m pytest tests/ -v
    -
    -
    Exit code 1
    +
    Exit code 1
     ===== FAILURES =====
    -test_subtract - AssertionError: expected 5 but got None
    +test_subtract - AssertionError: expected 5 but got None

    There's a test failure. I need to check the test file and fix it.

    Here's some markdown content with: @@ -261,21 +263,21 @@

    Claude C

    Fix the issue and commit

    -
    +
    +
    โœ๏ธ Edit test_math.py (replace all)
    /project/tests/test_math.py
    โˆ’
    assert subtract(10, 5) == None
    +
    assert subtract(10, 5) == 5
    -
    -
    File edited successfully
    +
    File edited successfully
    +
    $ Bash
    Commit the fix
    git add . && git commit -m 'Add subtract function and fix tests'
    -
    -
    2 files changed, 10 insertions(+), 1 deletion(-)
    +
    2 files changed, 10 insertions(+), 1 deletion(-)

    Done! The subtract function is now working and committed.

    Session continuation summary
    diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 2d46a78..067a872 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -40,6 +40,8 @@ .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); } +.tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } +.tool-pair .tool-use, .tool-pair .tool-result { margin: 8px 0; } .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; } @@ -159,7 +161,8 @@

    Claude C

    Add a multiply function too

    -
    +
    +
    โœ๏ธ Edit math_utils.py
    /project/math_utils.py
    @@ -173,8 +176,7 @@

    Claude C return a * b

    -
    -
    File edited successfully
    +
    File edited successfully

    Added multiply function!

    diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index b79542b..355faac 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -77,6 +77,14 @@ def test_generates_page_001_html(self, output_dir, snapshot_html): page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") assert page_html == snapshot_html + def test_pairs_tool_use_and_result(self, output_dir): + """Test that tool_use blocks are grouped with tool_result blocks.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + assert 'class="tool-pair"' in page_html + def test_generates_page_002_html(self, output_dir, snapshot_html): """Test page-002.html generation (continuation page).""" fixture_path = Path(__file__).parent / "sample_session.json" From 03c495a656510cd6da42c618e7223f641be0bf38 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 03:19:14 -0500 Subject: [PATCH 50/62] Harden ANSI escape sanitization Add OSC and CSI stripping with tests --- src/claude_code_transcripts/__init__.py | 19 +++++++++++++++ tests/test_generate_html.py | 31 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 8729fad..15d9f9e 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -48,6 +48,23 @@ def get_template(name): 300 # Characters - text blocks longer than this are shown in index ) +# Regex to strip ANSI escape sequences from terminal output +ANSI_ESCAPE_PATTERN = re.compile( + r""" + \x1b(?:\].*?(?:\x07|\x1b\\) # OSC sequences + |\[[0-?]*[ -/]*[@-~] # CSI sequences + |[@-Z\\-_]) # 7-bit C1 control codes + """, + re.VERBOSE | re.DOTALL, +) + + +def strip_ansi(text): + """Strip ANSI escape sequences from terminal output.""" + if not text: + return text + return ANSI_ESCAPE_PATTERN.sub("", text) + def extract_text_from_content(content): """Extract plain text from message content. @@ -711,6 +728,7 @@ def render_content_block(block): # Check for git commits and render with styled cards if isinstance(content, str): + content = strip_ansi(content) commits_found = list(COMMIT_PATTERN.finditer(content)) if commits_found: # Build commit cards + remaining content @@ -2071,4 +2089,5 @@ def on_progress(project_name, session_name, current, total): def main(): + # print("RUNNING LOCAL VERSION!!") cli() diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index b79542b..23c9a2f 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -18,6 +18,7 @@ render_edit_tool, render_bash_tool, render_content_block, + strip_ansi, analyze_conversation, format_tool_stats, is_tool_result_message, @@ -284,6 +285,21 @@ def test_tool_result_error(self, snapshot_html): result = render_content_block(block) assert result == snapshot_html + def test_tool_result_with_ansi_codes(self): + """Test that ANSI escape codes are stripped from tool results.""" + block = { + "type": "tool_result", + "content": "\x1b[38;2;166;172;186mTests passed:\x1b[0m \x1b[32mโœ“\x1b[0m All 5 tests passed\n\x1b[1;31mError:\x1b[0m None", + "is_error": False, + } + result = render_content_block(block) + assert "\x1b[" not in result + assert "[38;2;" not in result + assert "[32m" not in result + assert "[0m" not in result + assert "Tests passed:" in result + assert "All 5 tests passed" in result + def test_tool_result_with_commit(self, snapshot_html): """Test tool result with git commit output.""" # Need to set the global _github_repo for commit link rendering @@ -303,6 +319,21 @@ def test_tool_result_with_commit(self, snapshot_html): claude_code_transcripts._github_repo = old_repo +class TestStripAnsi: + """Tests for ANSI escape stripping.""" + + def test_strips_csi_sequences(self): + text = "start\x1b[?25hend\x1b[2Jdone" + assert strip_ansi(text) == "startenddone" + + def test_strips_osc_sequences(self): + text = "title\x1b]0;My Title\x07end" + assert strip_ansi(text) == "titleend" + + def test_strips_osc_st_terminator(self): + text = "name\x1b]0;Title\x1b\\end" + assert strip_ansi(text) == "nameend" + class TestAnalyzeConversation: """Tests for conversation analysis.""" From 5490fd48cf6a22ca7fbf7b6d2cb3bb9c3ba90f8a Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 04:54:36 -0500 Subject: [PATCH 51/62] Format code and update snapshots after merge --- src/claude_code_transcripts/__init__.py | 12 ++++++++++-- .../TestGenerateHtml.test_generates_index_html.html | 2 ++ ...stParseSessionFile.test_jsonl_generates_html.html | 2 ++ tests/test_generate_html.py | 1 + 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 928b361..45c8fe7 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1508,7 +1508,11 @@ def generate_html(json_path, output_dir, github_repo=None): 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 + log_type, + message_data, + timestamp, + tool_result_lookup, + paired_tool_ids, ) if msg_html: # Wrap continuation summaries in collapsed details @@ -2002,7 +2006,11 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): 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 + log_type, + message_data, + timestamp, + tool_result_lookup, + paired_tool_ids, ) if msg_html: # Wrap continuation summaries in collapsed details diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 3b6ff06..7861a0e 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -40,6 +40,8 @@ .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); } +.tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } +.tool-pair .tool-use, .tool-pair .tool-result { margin: 8px 0; } .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; } diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index 04e74f9..91ed230 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -40,6 +40,8 @@ .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); } +.tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } +.tool-pair .tool-use, .tool-pair .tool-result { margin: 8px 0; } .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; } diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 6b4cfca..089448b 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -408,6 +408,7 @@ def test_strips_osc_st_terminator(self): text = "name\x1b]0;Title\x1b\\end" assert strip_ansi(text) == "nameend" + class TestAnalyzeConversation: """Tests for conversation analysis.""" From 70d36c9db35f7c99a22f5fb767b3fa0bebc35eac Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 19:43:58 -0500 Subject: [PATCH 52/62] Start Phase 2: collapsible cells and tool markdown From 008f43fcee062a7b100e837d0e3213a73b4e5f74 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 19:47:54 -0500 Subject: [PATCH 53/62] Add Markdown rendering for tool descriptions and JSON string values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add render_json_with_markdown() function that recursively renders JSON with Markdown formatting for string values - Update render_bash_tool() to render descriptions as Markdown HTML - Update generic tool_use handler to render descriptions and JSON with Markdown - Update bash_tool and tool_use macros to use |safe for pre-rendered HTML - Add CSS classes for styled JSON output (json-key, json-string-value, etc.) - Add 4 new tests for markdown rendering functionality - Update snapshots for changed output format This implements Phase 2 Task 1: tool call text rendered as Markdown. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/claude_code_transcripts/__init__.py | 79 ++++++++++++++++++- .../templates/macros.html | 18 ++--- ...enerateHtml.test_generates_index_html.html | 12 +++ ...rateHtml.test_generates_page_001_html.html | 40 ++++++---- ...rateHtml.test_generates_page_002_html.html | 12 +++ ...SessionFile.test_jsonl_generates_html.html | 12 +++ ...ult_content_block_array_with_tool_use.html | 2 +- ...RenderFunctions.test_render_bash_tool.html | 2 +- tests/test_generate_html.py | 44 +++++++++++ 9 files changed, 192 insertions(+), 29 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 45c8fe7..15eadfd 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -727,6 +727,63 @@ def render_markdown_text(text): 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 @@ -767,10 +824,11 @@ def render_edit_tool(tool_input, tool_id): def render_bash_tool(tool_input, tool_id): - """Render Bash tool calls with command as plain text.""" + """Render Bash tool calls with command as plain text and description as Markdown.""" command = tool_input.get("command", "") description = tool_input.get("description", "") - return _macros.bash_tool(command, description, tool_id) + description_html = render_markdown_text(description) if description else "" + return _macros.bash_tool(command, description_html, tool_id) def render_content_block(block): @@ -801,9 +859,10 @@ def render_content_block(block): 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_json = json.dumps(display_input, indent=2, ensure_ascii=False) - return _macros.tool_use(tool_name, description, input_json, tool_id) + input_html = render_json_with_markdown(display_input) + return _macros.tool_use(tool_name, description_html, input_html, tool_id) elif block_type == "tool_result": content = block.get("content", "") is_error = block.get("is_error", False) @@ -1113,6 +1172,18 @@ def render_message_with_tool_pairs( .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-description p { margin: 0; } +.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; } +.json-key { color: #0d47a1; } +.json-string-value { color: #1b5e20; } +.json-string-value p { display: inline; margin: 0; } +.json-string-value code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } +.json-string-value strong { font-weight: 600; } +.json-string-value em { font-style: italic; } +.json-string-value a { color: #1976d2; text-decoration: underline; } +.json-number { color: #e65100; } +.json-bool { color: #7b1fa2; } +.json-null { color: #78909c; } .tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } .tool-result.tool-error { background: var(--tool-error-bg); } .tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index c5d26b8..c698861 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -90,24 +90,24 @@

    {%- endmacro %} -{# Bash tool #} -{% macro bash_tool(command, description, tool_id) %} +{# Bash tool - description_html is pre-rendered markdown so needs |safe #} +{% macro bash_tool(command, description_html, tool_id) %}
    $ Bash
    -{%- if description %} -
    {{ description }}
    +{%- if description_html %} +
    {{ description_html|safe }}
    {%- endif -%}
    {{ command }}
    {%- endmacro %} -{# Generic tool use - input_json is pre-formatted so needs |safe #} -{% macro tool_use(tool_name, description, input_json, tool_id) %} +{# Generic tool use - description_html and input_html are pre-rendered so need |safe #} +{% macro tool_use(tool_name, description_html, input_html, tool_id) %}
    โš™ {{ tool_name }}
    -{%- if description -%} -
    {{ description }}
    +{%- if description_html -%} +
    {{ description_html|safe }}
    {%- endif -%} -
    {{ input_json }}
    +
    {{ input_html|safe }}
    {%- endmacro %} {# Tool result - content_html is pre-rendered so needs |safe #} diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 7861a0e..773751a 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -38,6 +38,18 @@ .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-description p { margin: 0; } +.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; } +.json-key { color: #0d47a1; } +.json-string-value { color: #1b5e20; } +.json-string-value p { display: inline; margin: 0; } +.json-string-value code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } +.json-string-value strong { font-weight: 600; } +.json-string-value em { font-style: italic; } +.json-string-value a { color: #1976d2; text-decoration: underline; } +.json-number { color: #e65100; } +.json-bool { color: #7b1fa2; } +.json-null { color: #78909c; } .tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } .tool-result.tool-error { background: var(--tool-error-bg); } .tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index a0752af..faff84c 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -38,6 +38,18 @@ .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-description p { margin: 0; } +.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; } +.json-key { color: #0d47a1; } +.json-string-value { color: #1b5e20; } +.json-string-value p { display: inline; margin: 0; } +.json-string-value code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } +.json-string-value strong { font-weight: 600; } +.json-string-value em { font-style: italic; } +.json-string-value a { color: #1976d2; text-decoration: underline; } +.json-number { color: #e65100; } +.json-bool { color: #7b1fa2; } +.json-null { color: #78909c; } .tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } .tool-result.tool-error { background: var(--tool-error-bg); } .tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } @@ -214,7 +226,7 @@

    Claude C
    $ Bash
    -
    Run pytest on tests directory
    python -m pytest tests/
    +

    Run pytest on tests directory

    python -m pytest tests/

    Committed! Now pushing to remote.

    $ Bash
    -
    Push to remote
    git push -u origin main
    +

    Push to remote

    git push -u origin main
    remote: 
     remote: Create a pull request for 'main' on GitHub by visiting:
     remote:      https://github.com/example/project/pull/new/main
    @@ -247,10 +259,10 @@ 

    Claude C

    Now edit the file to add a subtract function

    -
    โš™ Glob
    {
    -  "pattern": "**/*.py",
    -  "path": "/project"
    -}
    /project/math_utils.py
    +
    โš™ Glob
    { + "pattern": */.py, + "path": /project +}
    /project/math_utils.py
     /project/tests/test_math.py
    @@ -271,18 +283,18 @@

    Claude C

    Added the subtract function. Let me verify with grep.

    -
    โš™ Grep
    {
    -  "pattern": "def subtract",
    -  "path": "/project",
    -  "output_mode": "content"
    -}
    /project/math_utils.py:6:def subtract(a: int, b: int) -> int:
    +
    โš™ Grep
    { + "pattern": def subtract, + "path": /project, + "output_mode": content +}
    /project/math_utils.py:6:def subtract(a: int, b: int) -> int:

    Run the tests again

    $ Bash
    -
    Run tests with verbose output
    python -m pytest tests/ -v
    +

    Run tests with verbose output

    python -m pytest tests/ -v
    Exit code 1
     ===== FAILURES =====
     test_subtract - AssertionError: expected 5 but got None
    @@ -312,7 +324,7 @@

    Claude C

    Done! The subtract function is now working and committed.

    Session continuation summary diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 9713935..ccaa156 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -38,6 +38,18 @@ .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-description p { margin: 0; } +.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; } +.json-key { color: #0d47a1; } +.json-string-value { color: #1b5e20; } +.json-string-value p { display: inline; margin: 0; } +.json-string-value code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } +.json-string-value strong { font-weight: 600; } +.json-string-value em { font-style: italic; } +.json-string-value a { color: #1976d2; text-decoration: underline; } +.json-number { color: #e65100; } +.json-bool { color: #7b1fa2; } +.json-null { color: #78909c; } .tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } .tool-result.tool-error { background: var(--tool-error-bg); } .tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index 91ed230..d633c8a 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -38,6 +38,18 @@ .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-description p { margin: 0; } +.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; } +.json-key { color: #0d47a1; } +.json-string-value { color: #1b5e20; } +.json-string-value p { display: inline; margin: 0; } +.json-string-value code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } +.json-string-value strong { font-weight: 600; } +.json-string-value em { font-style: italic; } +.json-string-value a { color: #1976d2; text-decoration: underline; } +.json-number { color: #e65100; } +.json-bool { color: #7b1fa2; } +.json-null { color: #78909c; } .tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } .tool-result.tool-error { background: var(--tool-error-bg); } .tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html index 5299d2b..f63173f 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html @@ -1,5 +1,5 @@
    $ Bash
    -
    List files
    ls -la
    +

    List files

    ls -la
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html index 716afb7..02a15c7 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html @@ -1,5 +1,5 @@
    $ Bash
    -
    Run tests with verbose output
    pytest tests/ -v
    +

    Run tests with verbose output

    pytest tests/ -v
    \ No newline at end of file diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 089448b..e8bd02f 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -11,6 +11,7 @@ generate_html, detect_github_repo, render_markdown_text, + render_json_with_markdown, format_json, is_json_like, render_todo_write, @@ -213,6 +214,49 @@ def test_render_bash_tool(self, snapshot_html): result = render_bash_tool(tool_input, "tool-123") assert result == snapshot_html + def test_render_bash_tool_markdown_description(self): + """Test Bash tool renders description as Markdown.""" + tool_input = { + "command": "echo hello", + "description": "This is **bold** and _italic_ text", + } + result = render_bash_tool(tool_input, "tool-123") + assert "bold" in result + assert "italic" in result + + def test_render_json_with_markdown_simple(self): + """Test JSON rendering with Markdown in string values.""" + obj = {"key": "This is **bold** text"} + result = render_json_with_markdown(obj) + assert "json-key" in result + assert "json-string-value" in result + assert "bold" in result + + def test_render_json_with_markdown_nested(self): + """Test nested JSON rendering with Markdown.""" + obj = { + "outer": {"inner": "Contains `code` markup"}, + "list": ["item with **bold**", "plain item"], + } + result = render_json_with_markdown(obj) + assert "code" in result + assert "bold" in result + + def test_render_json_with_markdown_types(self): + """Test JSON rendering preserves non-string types.""" + obj = { + "string": "text", + "number": 42, + "float": 3.14, + "bool_true": True, + "bool_false": False, + "null": None, + } + result = render_json_with_markdown(obj) + assert "json-number" in result + assert "json-bool" in result + assert "json-null" in result + class TestRenderContentBlock: """Tests for render_content_block function.""" From fe23fc53f35206b39523c6ee772fbbce7039061c Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Wed, 31 Dec 2025 19:50:36 -0500 Subject: [PATCH 54/62] Add collapsible cell structure for assistant messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add group_blocks_by_type() function to categorize content blocks - Refactor render_assistant_message*() to group blocks into cells - Add cell macro with
    wrapper for collapsible sections - Three cell types: thinking (closed), response (open), tools (closed with count) - Add CSS for cell styling with expand/collapse animation - Add 5 new tests for cell structure functionality - Update snapshots for new HTML structure Thinking cells are closed by default to reduce noise. Response cells are open by default for immediate visibility. Tools cells show count and are closed by default. This implements Phase 2 Task 2: collapsible cells for subcomponents. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/claude_code_transcripts/__init__.py | 120 +++++++++++-- .../templates/macros.html | 10 ++ ...enerateHtml.test_generates_index_html.html | 14 ++ ...rateHtml.test_generates_page_001_html.html | 166 ++++++++++++++++-- ...rateHtml.test_generates_page_002_html.html | 30 +++- ...SessionFile.test_jsonl_generates_html.html | 14 ++ tests/test_generate_html.py | 68 +++++++ 7 files changed, 385 insertions(+), 37 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 15eadfd..d7c2f31 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -966,35 +966,109 @@ def render_user_message_content_with_tool_pairs(message_data, paired_tool_ids): 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))}

    " - parts = [] - for block in content: - if not isinstance(block, dict): - parts.append(f"

    {html.escape(str(block))}

    ") - continue - if block.get("type") == "tool_use": - tool_id = block.get("id", "") - 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) - parts.append(_macros.tool_pair(tool_use_html, tool_result_html)) + + # 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"] + ) + cells.append(_macros.cell("thinking", "Thinking", thinking_html, False, 0)) + + # Render response cell (open by default) + if groups["text"]: + text_html = "".join(render_content_block(block) for block in groups["text"]) + cells.append(_macros.cell("response", "Response", text_html, True, 0)) + + # Render tools cell with pairing (closed by default) + if groups["tools"]: + tool_parts = [] + for block in groups["tools"]: + if not isinstance(block, dict): + tool_parts.append(f"

    {html.escape(str(block))}

    ") continue - parts.append(render_content_block(block)) - return "".join(parts) + if block.get("type") == "tool_use": + tool_id = block.get("id", "") + 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) + ) + continue + tool_parts.append(render_content_block(block)) + tools_html = "".join(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)) + + 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))}

    " - return "".join(render_content_block(block) for block in 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"] + ) + cells.append(_macros.cell("thinking", "Thinking", thinking_html, False, 0)) + + # Render response cell (open by default) + if groups["text"]: + text_html = "".join(render_content_block(block) for block in groups["text"]) + cells.append(_macros.cell("response", "Response", text_html, True, 0)) + + # Render tools cell (closed by default) + if groups["tools"]: + tools_html = "".join(render_content_block(block) for block in groups["tools"]) + 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)) + + return "".join(cells) def make_msg_id(timestamp): @@ -1168,6 +1242,20 @@ def render_message_with_tool_pairs( .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; } +.cell { margin: 8px 0; border-radius: 8px; overflow: hidden; } +.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; } +.cell summary::-webkit-details-marker { display: none; } +.cell summary::before { content: 'โ–ถ'; font-size: 0.7rem; margin-right: 8px; transition: transform 0.2s; } +.cell[open] summary::before { transform: rotate(90deg); } +.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; } +.thinking-cell[open] summary { border-radius: 8px 8px 0 0; } +.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; } +.response-cell[open] summary { border-radius: 8px 8px 0 0; } +.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; } +.tools-cell[open] summary { border-radius: 8px 8px 0 0; } +.cell-content { padding: 12px 16px; border: 1px solid rgba(0,0,0,0.1); border-top: none; border-radius: 0 0 8px 8px; background: var(--card-bg); } +.thinking-cell .cell-content { background: var(--thinking-bg); border-color: var(--thinking-border); } +.tools-cell .cell-content { background: rgba(243, 229, 245, 0.3); border-color: var(--tool-border); } .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; } diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index c698861..ea05383 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -121,6 +121,16 @@
    {{ tool_use_html|safe }}{{ tool_result_html|safe }}
    {%- endmacro %} +{# Collapsible cell wrapper for message sections #} +{% macro cell(cell_type, label, content_html, open_by_default=false, count=0) %} +
    + +{{ label }}{% if count %} ({{ count }}){% endif %} + +
    {{ content_html|safe }}
    +
    +{%- endmacro %} + {# Thinking block - content_html is pre-rendered markdown so needs |safe #} {% macro thinking(content_html) %}
    Thinking
    {{ content_html|safe }}
    diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 773751a..3a75f9c 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -34,6 +34,20 @@ .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; } +.cell { margin: 8px 0; border-radius: 8px; overflow: hidden; } +.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; } +.cell summary::-webkit-details-marker { display: none; } +.cell summary::before { content: 'โ–ถ'; font-size: 0.7rem; margin-right: 8px; transition: transform 0.2s; } +.cell[open] summary::before { transform: rotate(90deg); } +.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; } +.thinking-cell[open] summary { border-radius: 8px 8px 0 0; } +.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; } +.response-cell[open] summary { border-radius: 8px 8px 0 0; } +.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; } +.tools-cell[open] summary { border-radius: 8px 8px 0 0; } +.cell-content { padding: 12px 16px; border: 1px solid rgba(0,0,0,0.1); border-top: none; border-radius: 0 0 8px 8px; background: var(--card-bg); } +.thinking-cell .cell-content { background: var(--thinking-bg); border-color: var(--thinking-border); } +.tools-cell .cell-content { background: rgba(243, 229, 245, 0.3); border-color: var(--tool-border); } .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; } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index faff84c..95a2403 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -34,6 +34,20 @@ .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; } +.cell { margin: 8px 0; border-radius: 8px; overflow: hidden; } +.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; } +.cell summary::-webkit-details-marker { display: none; } +.cell summary::before { content: 'โ–ถ'; font-size: 0.7rem; margin-right: 8px; transition: transform 0.2s; } +.cell[open] summary::before { transform: rotate(90deg); } +.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; } +.thinking-cell[open] summary { border-radius: 8px 8px 0 0; } +.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; } +.response-cell[open] summary { border-radius: 8px 8px 0 0; } +.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; } +.tools-cell[open] summary { border-radius: 8px 8px 0 0; } +.cell-content { padding: 12px 16px; border: 1px solid rgba(0,0,0,0.1); border-top: none; border-radius: 0 0 8px 8px; background: var(--card-bg); } +.thinking-cell .cell-content { background: var(--thinking-bg); border-color: var(--thinking-border); } +.tools-cell .cell-content { background: rgba(243, 229, 245, 0.3); border-color: var(--tool-border); } .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; } @@ -208,11 +222,28 @@

    Claude C

    Create a simple Python function to add two numbers

    +
    + +Thinking + +
    Thinking

    The user wants a simple addition function. I should: 1. Create the function 2. Add a basic test

    -

    This is straightforward.

    -

    I'll create a simple Python function for you. Let me write it now.

    +

    This is straightforward.

    +

    +
    + +Response + +
    +

    I'll create a simple Python function for you. Let me write it now.

    +
    +
    + +Tool Calls (1) + +
    ๐Ÿ“ Write math_utils.py
    /project/math_utils.py
    @@ -220,9 +251,21 @@

    Claude C """Add two numbers together.""" return a + b

    -
    +
    +

    -

    I've created the add function. Now let me run the tests.

    +
    + +Response + +
    +

    I've created the add function. Now let me run the tests.

    +
    +
    + +Tool Calls (1) + +
    +===== 2 passed in 0.05s ===== +
    -

    All tests pass. Let me update the todo list and make a commit.

    +
    + +Response + +
    +

    All tests pass. Let me update the todo list and make a commit.

    +
    +
    + +Tool Calls (1) + +
    -
    โ˜ฐ Task List
    • โœ“Create add function
    • โœ“Write tests
    • โœ“Run tests
    • โ†’Commit changes
    • โ—‹Push to remote
    Todos updated
    +
    โ˜ฐ Task List
    • โœ“Create add function
    • โœ“Write tests
    • โœ“Run tests
    • โ†’Commit changes
    • โ—‹Push to remote
    Todos updated
    +
    +
    + +Tool Calls (1) + +
    $ Bash

    Commit changes

    git add . && git commit -m 'Add math_utils with add function'
    -
    1 file changed, 5 insertions(+)
    +
    1 file changed, 5 insertions(+)
    +
    -

    Committed! Now pushing to remote.

    +
    + +Response + +
    +

    Committed! Now pushing to remote.

    +
    +
    + +Tool Calls (1) + +
    + def5678..abc1234 main -> main +

    Now edit the file to add a subtract function

    +
    + +Tool Calls (1) + +
    โš™ Glob
    { "pattern": */.py, "path": /project }
    /project/math_utils.py
    -/project/tests/test_math.py
    +/project/tests/test_math.py
    +
    +
    + +Tool Calls (1) + +
    โœ๏ธ Edit math_utils.py
    /project/math_utils.py
    @@ -279,26 +364,50 @@

    Claude C return a - b

    -
    +
    File edited successfully
    +
    -

    Added the subtract function. Let me verify with grep.

    +
    + +Response + +
    +

    Added the subtract function. Let me verify with grep.

    +
    +
    + +Tool Calls (1) + +
    โš™ Grep
    { "pattern": def subtract, "path": /project, "output_mode": content -}
    /project/math_utils.py:6:def subtract(a: int, b: int) -> int:
    +}
    /project/math_utils.py:6:def subtract(a: int, b: int) -> int:
    +

    Run the tests again

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

    Run tests with verbose output

    python -m pytest tests/ -v
    Exit code 1
     ===== FAILURES =====
    -test_subtract - AssertionError: expected 5 but got None
    +test_subtract - AssertionError: expected 5 but got None
    +
    +
    + +Response + +

    There's a test failure. I need to check the test file and fix it.

    Here's some markdown content with: - A bullet list @@ -306,10 +415,16 @@

    Claude C - A link

    def example():
         return 42
    -

    +
    +

    Fix the issue and commit

    +
    + +Tool Calls (1) + +
    โœ๏ธ Edit test_math.py (replace all)
    /project/tests/test_math.py
    @@ -319,15 +434,28 @@

    Claude C
    +
    assert subtract(10, 5) == 5
     

    -
    +
    File edited successfully
    +
    +
    + +Tool Calls (1) + +
    $ Bash

    Commit the fix

    git add . && git commit -m 'Add subtract function and fix tests'
    -
    2 files changed, 10 insertions(+), 1 deletion(-)
    +
    2 files changed, 10 insertions(+), 1 deletion(-)
    +
    -

    Done! The subtract function is now working and committed.

    Session continuation summary +
    + +Response + +
    +

    Done! The subtract function is now working and committed.

    +
    Session continuation summary

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

    diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index ccaa156..0475333 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -34,6 +34,20 @@ .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; } +.cell { margin: 8px 0; border-radius: 8px; overflow: hidden; } +.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; } +.cell summary::-webkit-details-marker { display: none; } +.cell summary::before { content: 'โ–ถ'; font-size: 0.7rem; margin-right: 8px; transition: transform 0.2s; } +.cell[open] summary::before { transform: rotate(90deg); } +.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; } +.thinking-cell[open] summary { border-radius: 8px 8px 0 0; } +.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; } +.response-cell[open] summary { border-radius: 8px 8px 0 0; } +.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; } +.tools-cell[open] summary { border-radius: 8px 8px 0 0; } +.cell-content { padding: 12px 16px; border: 1px solid rgba(0,0,0,0.1); border-top: none; border-radius: 0 0 8px 8px; background: var(--card-bg); } +.thinking-cell .cell-content { background: var(--thinking-bg); border-color: var(--thinking-border); } +.tools-cell .cell-content { background: rgba(243, 229, 245, 0.3); border-color: var(--tool-border); } .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; } @@ -208,6 +222,11 @@

    Claude C

    Add a multiply function too

    +
    + +Tool Calls (1) + +
    โœ๏ธ Edit math_utils.py
    /project/math_utils.py
    @@ -222,9 +241,16 @@

    Claude C return a * b

    -
    +
    File edited successfully
    +

    -

    Added multiply function!

    +
    + +Response + +
    +

    Added multiply function!

    +
    Tool Calls (1) - +
    -
    $ Bash
    +
    โ†’ Call$ Bash

    Commit the fix

    git add . && git commit -m 'Add subtract function and fix tests'
    -
    2 files changed, 10 insertions(+), 1 deletion(-)
    +
    โ† Result
    2 files changed, 10 insertions(+), 1 deletion(-)
    [main def5678] Add subtract function and fix tests
    + 2 files changed, 10 insertions(+), 1 deletion(-)
    Response - +

    Done! The subtract function is now working and committed.

    @@ -553,9 +723,15 @@

    Claude C btn.addEventListener('click', function(e) { e.stopPropagation(); e.preventDefault(); - const cell = btn.closest('.cell'); - const content = cell.querySelector('.cell-content'); - const textToCopy = content.textContent.trim(); + // 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(); + } navigator.clipboard.writeText(textToCopy).then(function() { btn.textContent = 'Copied!'; btn.classList.add('copied'); @@ -575,6 +751,15 @@

    Claude C } }); }); +// Toggle between JSON and Markdown views for tool calls/results +document.querySelectorAll('.view-toggle-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var container = btn.closest('.tool-use, .tool-result'); + container.classList.toggle('show-json'); + btn.textContent = container.classList.contains('show-json') ? 'Markdown' : 'JSON'; + }); +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index bd54a1d..b6afdfc 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -12,14 +12,14 @@ 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: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +.message { margin-bottom: 16px; border-radius: 12px; 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; } +.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; border-radius: 12px 12px 0 0; } .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; } @@ -34,16 +34,19 @@ .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; } -.cell { margin: 8px 0; border-radius: 8px; overflow: hidden; } -.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; } +.cell { margin: 8px 0; border-radius: 8px; overflow: visible; } +.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; position: sticky; top: 0; z-index: 10; } .cell summary::-webkit-details-marker { display: none; } .cell summary::before { content: 'โ–ถ'; font-size: 0.7rem; margin-right: 8px; transition: transform 0.2s; } .cell[open] summary::before { transform: rotate(90deg); } -.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; } +.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; transition: background 0.15s, border-color 0.15s; } +.thinking-cell summary:hover { background: rgba(255, 243, 224, 0.9); border-color: #f57c00; } .thinking-cell[open] summary { border-radius: 8px 8px 0 0; } -.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; } +.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; transition: background 0.15s, border-color 0.15s; } +.response-cell summary:hover { background: rgba(0,0,0,0.06); border-color: rgba(0,0,0,0.2); } .response-cell[open] summary { border-radius: 8px 8px 0 0; } -.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; } +.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; transition: background 0.15s, border-color 0.15s; } +.tools-cell summary:hover { background: rgba(243, 229, 245, 0.8); border-color: #7b1fa2; } .tools-cell[open] summary { border-radius: 8px 8px 0 0; } .cell-content { padding: 12px 16px; border: 1px solid rgba(0,0,0,0.1); border-top: none; border-radius: 0 0 8px 8px; background: var(--card-bg); } .thinking-cell .cell-content { background: var(--thinking-bg); border-color: var(--thinking-border); } @@ -58,16 +61,28 @@ .tool-description { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; font-style: italic; } .tool-description p { margin: 0; } .tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; } -.json-key { color: #0d47a1; } -.json-string-value { color: #1b5e20; } +.view-toggle-btn { padding: 2px 8px; font-size: 0.7rem; background: rgba(255,255,255,0.8); border: 1px solid rgba(0,0,0,0.15); border-radius: 4px; cursor: pointer; margin-left: auto; transition: background 0.15s; } +.view-toggle-btn:hover { background: rgba(255,255,255,1); } +.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: 8px; } +.tool-result-label { font-weight: 600; font-size: 0.85rem; color: #2e7d32; display: flex; align-items: center; gap: 6px; } +.tool-result.tool-error .tool-result-label { color: #c62828; } +.result-icon { font-size: 1rem; } +.tool-call-label { font-weight: 600; font-size: 0.8rem; color: var(--tool-border); background: rgba(156, 39, 176, 0.12); padding: 2px 8px; border-radius: 4px; margin-right: 8px; display: inline-flex; align-items: center; gap: 4px; } +.call-icon { font-size: 0.9rem; } +.json-key { color: #7b1fa2; font-weight: 600; } +.json-string-value { color: #0d5c1e; } .json-string-value p { display: inline; margin: 0; } .json-string-value code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } .json-string-value strong { font-weight: 600; } .json-string-value em { font-style: italic; } .json-string-value a { color: #1976d2; text-decoration: underline; } -.json-number { color: #e65100; } -.json-bool { color: #7b1fa2; } -.json-null { color: #78909c; } +.json-number { color: #c62828; font-weight: 500; } +.json-bool { color: #1565c0; font-weight: 600; } +.json-null { color: #78909c; 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); } .tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } @@ -112,33 +127,33 @@ code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre code { background: none; padding: 0; } .highlight .hll { background-color: #49483e } -.highlight .c { color: #75715e } /* Comment */ -.highlight .err { color: #f92672 } /* Error */ -.highlight .k { color: #66d9ef } /* Keyword */ -.highlight .l { color: #ae81ff } /* Literal */ -.highlight .n { color: #e0e0e0 } /* Name */ -.highlight .o { color: #f92672 } /* Operator */ -.highlight .p { color: #e0e0e0 } /* Punctuation */ -.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #75715e } /* Comments */ -.highlight .gd { color: #f92672 } /* Generic.Deleted */ -.highlight .gi { color: #a6e22e } /* Generic.Inserted */ -.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #66d9ef } /* Keywords */ -.highlight .ld { color: #e6db74 } /* Literal.Date */ -.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #ae81ff } /* Numbers */ -.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: #e6db74 } /* Strings */ -.highlight .na { color: #a6e22e } /* Name.Attribute */ -.highlight .nb { color: #e0e0e0 } /* Name.Builtin */ -.highlight .nc { color: #a6e22e } /* Name.Class */ -.highlight .no { color: #66d9ef } /* Name.Constant */ -.highlight .nd { color: #a6e22e } /* Name.Decorator */ -.highlight .ne { color: #a6e22e } /* Name.Exception */ -.highlight .nf { color: #a6e22e } /* Name.Function */ -.highlight .nl { color: #e0e0e0 } /* Name.Label */ -.highlight .nn { color: #e0e0e0 } /* Name.Namespace */ -.highlight .nt { color: #f92672 } /* Name.Tag */ -.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #e0e0e0 } /* Variables */ -.highlight .ow { color: #f92672 } /* Operator.Word */ -.highlight .w { color: #e0e0e0 } /* Text.Whitespace */ +.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; } .truncatable { position: relative; } .truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } @@ -229,7 +244,15 @@

    Claude C
    Tool Calls (1) - + -

    +
    โ† Result

    File edited successfully

    File edited successfully
    Response - +

    Added multiply function!

    @@ -329,9 +352,15 @@

    Claude C btn.addEventListener('click', function(e) { e.stopPropagation(); e.preventDefault(); - const cell = btn.closest('.cell'); - const content = cell.querySelector('.cell-content'); - const textToCopy = content.textContent.trim(); + // 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(); + } navigator.clipboard.writeText(textToCopy).then(function() { btn.textContent = 'Copied!'; btn.classList.add('copied'); @@ -351,6 +380,15 @@

    Claude C } }); }); +// Toggle between JSON and Markdown views for tool calls/results +document.querySelectorAll('.view-toggle-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var container = btn.closest('.tool-use, .tool-result'); + container.classList.toggle('show-json'); + btn.textContent = container.classList.contains('show-json') ? 'Markdown' : 'JSON'; + }); +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index 542ff43..deae124 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -12,14 +12,14 @@ 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: 16px; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +.message { margin-bottom: 16px; border-radius: 12px; 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; } +.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; border-radius: 12px 12px 0 0; } .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; } @@ -34,16 +34,19 @@ .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; } -.cell { margin: 8px 0; border-radius: 8px; overflow: hidden; } -.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; } +.cell { margin: 8px 0; border-radius: 8px; overflow: visible; } +.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; position: sticky; top: 0; z-index: 10; } .cell summary::-webkit-details-marker { display: none; } .cell summary::before { content: 'โ–ถ'; font-size: 0.7rem; margin-right: 8px; transition: transform 0.2s; } .cell[open] summary::before { transform: rotate(90deg); } -.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; } +.thinking-cell summary { background: var(--thinking-bg); border: 1px solid var(--thinking-border); color: #f57c00; border-radius: 8px; transition: background 0.15s, border-color 0.15s; } +.thinking-cell summary:hover { background: rgba(255, 243, 224, 0.9); border-color: #f57c00; } .thinking-cell[open] summary { border-radius: 8px 8px 0 0; } -.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; } +.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; transition: background 0.15s, border-color 0.15s; } +.response-cell summary:hover { background: rgba(0,0,0,0.06); border-color: rgba(0,0,0,0.2); } .response-cell[open] summary { border-radius: 8px 8px 0 0; } -.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; } +.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; transition: background 0.15s, border-color 0.15s; } +.tools-cell summary:hover { background: rgba(243, 229, 245, 0.8); border-color: #7b1fa2; } .tools-cell[open] summary { border-radius: 8px 8px 0 0; } .cell-content { padding: 12px 16px; border: 1px solid rgba(0,0,0,0.1); border-top: none; border-radius: 0 0 8px 8px; background: var(--card-bg); } .thinking-cell .cell-content { background: var(--thinking-bg); border-color: var(--thinking-border); } @@ -58,16 +61,28 @@ .tool-description { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; font-style: italic; } .tool-description p { margin: 0; } .tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: 0.85rem; line-height: 1.5; } -.json-key { color: #0d47a1; } -.json-string-value { color: #1b5e20; } +.view-toggle-btn { padding: 2px 8px; font-size: 0.7rem; background: rgba(255,255,255,0.8); border: 1px solid rgba(0,0,0,0.15); border-radius: 4px; cursor: pointer; margin-left: auto; transition: background 0.15s; } +.view-toggle-btn:hover { background: rgba(255,255,255,1); } +.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: 8px; } +.tool-result-label { font-weight: 600; font-size: 0.85rem; color: #2e7d32; display: flex; align-items: center; gap: 6px; } +.tool-result.tool-error .tool-result-label { color: #c62828; } +.result-icon { font-size: 1rem; } +.tool-call-label { font-weight: 600; font-size: 0.8rem; color: var(--tool-border); background: rgba(156, 39, 176, 0.12); padding: 2px 8px; border-radius: 4px; margin-right: 8px; display: inline-flex; align-items: center; gap: 4px; } +.call-icon { font-size: 0.9rem; } +.json-key { color: #7b1fa2; font-weight: 600; } +.json-string-value { color: #0d5c1e; } .json-string-value p { display: inline; margin: 0; } .json-string-value code { background: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } .json-string-value strong { font-weight: 600; } .json-string-value em { font-style: italic; } .json-string-value a { color: #1976d2; text-decoration: underline; } -.json-number { color: #e65100; } -.json-bool { color: #7b1fa2; } -.json-null { color: #78909c; } +.json-number { color: #c62828; font-weight: 500; } +.json-bool { color: #1565c0; font-weight: 600; } +.json-null { color: #78909c; 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); } .tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } @@ -112,33 +127,33 @@ code { background: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } pre code { background: none; padding: 0; } .highlight .hll { background-color: #49483e } -.highlight .c { color: #75715e } /* Comment */ -.highlight .err { color: #f92672 } /* Error */ -.highlight .k { color: #66d9ef } /* Keyword */ -.highlight .l { color: #ae81ff } /* Literal */ -.highlight .n { color: #e0e0e0 } /* Name */ -.highlight .o { color: #f92672 } /* Operator */ -.highlight .p { color: #e0e0e0 } /* Punctuation */ -.highlight .ch, .highlight .cm, .highlight .c1, .highlight .cs, .highlight .cp, .highlight .cpf { color: #75715e } /* Comments */ -.highlight .gd { color: #f92672 } /* Generic.Deleted */ -.highlight .gi { color: #a6e22e } /* Generic.Inserted */ -.highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #66d9ef } /* Keywords */ -.highlight .ld { color: #e6db74 } /* Literal.Date */ -.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo { color: #ae81ff } /* Numbers */ -.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: #e6db74 } /* Strings */ -.highlight .na { color: #a6e22e } /* Name.Attribute */ -.highlight .nb { color: #e0e0e0 } /* Name.Builtin */ -.highlight .nc { color: #a6e22e } /* Name.Class */ -.highlight .no { color: #66d9ef } /* Name.Constant */ -.highlight .nd { color: #a6e22e } /* Name.Decorator */ -.highlight .ne { color: #a6e22e } /* Name.Exception */ -.highlight .nf { color: #a6e22e } /* Name.Function */ -.highlight .nl { color: #e0e0e0 } /* Name.Label */ -.highlight .nn { color: #e0e0e0 } /* Name.Namespace */ -.highlight .nt { color: #f92672 } /* Name.Tag */ -.highlight .nv, .highlight .vc, .highlight .vg, .highlight .vi, .highlight .vm { color: #e0e0e0 } /* Variables */ -.highlight .ow { color: #f92672 } /* Operator.Word */ -.highlight .w { color: #e0e0e0 } /* Text.Whitespace */ +.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; } .truncatable { position: relative; } .truncatable.truncated .truncatable-content { max-height: 200px; overflow: hidden; } @@ -598,9 +613,15 @@

    Claude Code transcript

    btn.addEventListener('click', function(e) { e.stopPropagation(); e.preventDefault(); - const cell = btn.closest('.cell'); - const content = cell.querySelector('.cell-content'); - const textToCopy = content.textContent.trim(); + // 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(); + } navigator.clipboard.writeText(textToCopy).then(function() { btn.textContent = 'Copied!'; btn.classList.add('copied'); @@ -620,6 +641,15 @@

    Claude Code transcript

    } }); }); +// Toggle between JSON and Markdown views for tool calls/results +document.querySelectorAll('.view-toggle-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var container = btn.closest('.tool-use, .tool-result'); + container.classList.toggle('show-json'); + btn.textContent = container.classList.contains('show-json') ? 'Markdown' : 'JSON'; + }); +}); \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html index e4e3501..49d8749 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html @@ -1,3 +1,5 @@ -
    Command completed successfully
    +
    โ† Result

    Command completed successfully Output line 1 -Output line 2

    \ No newline at end of file +Output line 2

    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html index 28f5587..2db69ee 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html @@ -1,4 +1,9 @@ -
    +
    โ† Result

    Here is the file content:

    Line 1 -Line 2

    \ No newline at end of file +Line 2

    [
    +  {
    +    "type": "text",
    +    "text": "Here is the file content:\n\nLine 1\nLine 2"
    +  }
    +]
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html index 22761d2..dbfc691 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html @@ -1,2 +1,11 @@ -
    -
    \ No newline at end of file +
    โ† Result
    +
    [
    +  {
    +    "type": "image",
    +    "source": {
    +      "type": "base64",
    +      "media_type": "image/gif",
    +      "data": "R0lGODlhAQABAIAAAAUEBA=="
    +    }
    +  }
    +]
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html index f63173f..119c55f 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html @@ -1,5 +1,15 @@ -
    +
    โ† Result
    -
    $ Bash
    +
    โ†’ Call$ Bash

    List files

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

    Error: file not found +Traceback follows...

    Error: file not found
    +Traceback follows...
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html index e2700d3..3c01f12 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html @@ -1,2 +1,3 @@ -
    Tests passed: โœ“ All 5 tests passed
    -Error: None
    \ No newline at end of file +
    โ† Result

    Tests passed: โœ“ All 5 tests passed +Error: None

    Tests passed: โœ“ All 5 tests passed
    +Error: None
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html index d5e9dfb..aadeec1 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html @@ -1 +1,2 @@ -
    2 files changed, 10 insertions(+)
    \ No newline at end of file +
    โ† Result
    2 files changed, 10 insertions(+)
    [main abc1234] Add new feature
    + 2 files changed, 10 insertions(+)
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html index 02a15c7..f45914d 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html @@ -1,5 +1,5 @@
    -
    $ Bash
    +
    โ†’ Call$ Bash

    Run tests with verbose output

    pytest tests/ -v
    \ No newline at end of file From d66294174fd4cc34d9e85ccce678bb89c9e94a10 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Thu, 1 Jan 2026 02:34:43 -0500 Subject: [PATCH 57/62] Implement comprehensive UI improvements for Phase 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Phase 1: Foundation (Craft.do Style) - Add 50+ CSS custom properties for colors, spacing, shadows - Implement warm cream/off-white color palette - Add paper texture background effect - Define frosted glass variables for sticky headers ## Phase 2: User Messages - Make user messages collapsible with cell wrapper - Add text wrapping (overflow-wrap, word-break) to prevent overflow - Add .user-cell CSS styling matching other cell types ## Phase 3: Toggle System - Replace small toggle buttons with shadcn-inspired tab controls - Add Markdown | JSON tabs with clear active states - Include ARIA attributes for accessibility - Update JavaScript for tab-based toggle behavior ## Phase 4: Collapsibility Unification - Make message headers sticky (z-index: 30) - Create cascading sticky headers: message โ†’ cell โ†’ subcell - Add frosted glass backdrop-filter effects - Ensure no overflow:hidden breaks sticky positioning ## Phase 5: Copy System - Remove duplicate copy buttons (skip inside .cell-content) - Add cell-level master toggle for tools cells - Propagate view mode to all child elements ## Phase 6: Specialized Tools - Add Markdown/JSON toggles to Write, Edit, Bash, TodoWrite - Pass input_json_html to all specialized tool macros - Preserve existing specialized displays in Markdown view All 133 tests pass. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/claude_code_transcripts/__init__.py | 459 +++++++---- .../templates/macros.html | 79 +- ...enerateHtml.test_generates_index_html.html | 426 ++++++---- ...rateHtml.test_generates_page_001_html.html | 735 ++++++++++++++---- ...rateHtml.test_generates_page_002_html.html | 460 +++++++---- ...SessionFile.test_jsonl_generates_html.html | 426 ++++++---- ...erContentBlock.test_tool_result_block.html | 2 +- ....test_tool_result_content_block_array.html | 2 +- ...result_content_block_array_with_image.html | 2 +- ...ult_content_block_array_with_tool_use.html | 17 +- ...erContentBlock.test_tool_result_error.html | 2 +- ...lock.test_tool_result_with_ansi_codes.html | 2 +- ...entBlock.test_tool_result_with_commit.html | 2 +- ...RenderFunctions.test_render_bash_tool.html | 15 +- ...RenderFunctions.test_render_edit_tool.html | 16 +- ...ons.test_render_edit_tool_replace_all.html | 17 +- ...enderFunctions.test_render_todo_write.html | 33 +- ...enderFunctions.test_render_write_tool.html | 15 +- 18 files changed, 1941 insertions(+), 769 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 509dabd..42e31bb 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -797,7 +797,8 @@ def render_todo_write(tool_input, tool_id): todos = tool_input.get("todos", []) if not todos: return "" - return _macros.todo_list(todos, tool_id) + 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): @@ -806,7 +807,8 @@ def render_write_tool(tool_input, tool_id): content = tool_input.get("content", "") # Apply syntax highlighting based on file extension highlighted_content = highlight_code(content, filename=file_path) - return _macros.write_tool(file_path, highlighted_content, tool_id) + 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): @@ -818,8 +820,14 @@ def render_edit_tool(tool_input, tool_id): # 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, tool_id + file_path, + highlighted_old, + highlighted_new, + replace_all, + input_json_html, + tool_id, ) @@ -828,7 +836,8 @@ def render_bash_tool(tool_input, tool_id): command = tool_input.get("command", "") description = tool_input.get("description", "") description_html = render_markdown_text(description) if description else "" - return _macros.bash_tool(command, description_html, tool_id) + input_json_html = format_json(tool_input) + return _macros.bash_tool(command, description_html, input_json_html, tool_id) def render_content_block(block): @@ -937,10 +946,20 @@ def render_user_message_content(message_data): content = message_data.get("content", "") if isinstance(content, str): if is_json_like(content): - return _macros.user_content(format_json(content)) - return _macros.user_content(render_markdown_text(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): - return "".join(render_content_block(block) for block in content) + 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))}

    " @@ -1303,21 +1322,102 @@ def render_message_with_tool_pairs( 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; } +: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-color); color: var(--text-color); margin: 0; padding: 16px; line-height: 1.6; } +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: 16px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +.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: 8px 16px; background: rgba(0,0,0,0.03); font-size: 0.85rem; border-radius: 12px 12px 0 0; } +.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; } @@ -1325,104 +1425,111 @@ def render_message_with_tool_pairs( .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 { 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: 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 { 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: 8px 0; border-radius: 8px; overflow: visible; } -.cell summary { cursor: pointer; padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; font-weight: 600; font-size: 0.9rem; list-style: none; position: sticky; top: 0; z-index: 10; } +.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; justify-content: space-between; 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); } .cell summary::-webkit-details-marker { display: none; } -.cell summary::before { content: 'โ–ถ'; font-size: 0.7rem; margin-right: 8px; transition: transform 0.2s; } +.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: #f57c00; border-radius: 8px; transition: background 0.15s, border-color 0.15s; } -.thinking-cell summary:hover { background: rgba(255, 243, 224, 0.9); border-color: #f57c00; } -.thinking-cell[open] summary { border-radius: 8px 8px 0 0; } -.response-cell summary { background: rgba(0,0,0,0.03); border: 1px solid var(--assistant-border); color: var(--text-color); border-radius: 8px; transition: background 0.15s, border-color 0.15s; } -.response-cell summary:hover { background: rgba(0,0,0,0.06); border-color: rgba(0,0,0,0.2); } -.response-cell[open] summary { border-radius: 8px 8px 0 0; } -.tools-cell summary { background: var(--tool-bg); border: 1px solid var(--tool-border); color: var(--tool-border); border-radius: 8px; transition: background 0.15s, border-color 0.15s; } -.tools-cell summary:hover { background: rgba(243, 229, 245, 0.8); border-color: #7b1fa2; } -.tools-cell[open] summary { border-radius: 8px 8px 0 0; } -.cell-content { padding: 12px 16px; border: 1px solid rgba(0,0,0,0.1); border-top: none; border-radius: 0 0 8px 8px; background: var(--card-bg); } +.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: rgba(243, 229, 245, 0.3); border-color: var(--tool-border); } -.cell-copy-btn { padding: 4px 10px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.15); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); transition: all 0.2s; margin-left: auto; } -.cell-copy-btn:hover { background: white; color: var(--text-color); border-color: rgba(0,0,0,0.3); } -.cell-copy-btn:focus { outline: 2px solid var(--user-border); outline-offset: 2px; } -.cell-copy-btn.copied { background: #c8e6c9; color: #2e7d32; border-color: #a5d6a7; } -.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; } +.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; } +.tool-icon { font-size: var(--font-size-lg); } +.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: 0.85rem; line-height: 1.5; } -.view-toggle-btn { padding: 2px 8px; font-size: 0.7rem; background: rgba(255,255,255,0.8); border: 1px solid rgba(0,0,0,0.15); border-radius: 4px; cursor: pointer; margin-left: auto; transition: background 0.15s; } -.view-toggle-btn:hover { background: rgba(255,255,255,1); } +.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: 8px; } -.tool-result-label { font-weight: 600; font-size: 0.85rem; color: #2e7d32; display: flex; align-items: center; gap: 6px; } -.tool-result.tool-error .tool-result-label { color: #c62828; } -.result-icon { font-size: 1rem; } -.tool-call-label { font-weight: 600; font-size: 0.8rem; color: var(--tool-border); background: rgba(156, 39, 176, 0.12); padding: 2px 8px; border-radius: 4px; margin-right: 8px; display: inline-flex; align-items: center; gap: 4px; } -.call-icon { font-size: 0.9rem; } -.json-key { color: #7b1fa2; font-weight: 600; } -.json-string-value { color: #0d5c1e; } +.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: rgba(0,0,0,0.08); padding: 1px 4px; border-radius: 3px; } +.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: #1976d2; text-decoration: underline; } -.json-number { color: #c62828; font-weight: 500; } -.json-bool { color: #1565c0; font-weight: 600; } -.json-null { color: #78909c; font-style: italic; } -.tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; } +.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: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); } -.tool-pair .tool-use, .tool-pair .tool-result { margin: 8px 0; } -.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; } +.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); } +.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: 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; } +.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); } .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 { 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: #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; } +.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: rgba(0,0,0,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } +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 */ @@ -1452,7 +1559,7 @@ def render_message_with_tool_pairs( .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; } +.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; } @@ -1460,66 +1567,68 @@ def render_message_with_tool_pairs( .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); } +.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: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; } -.copy-btn:hover { background: white; color: var(--text-color); } -.copy-btn.copied { background: #c8e6c9; color: #2e7d32; } +.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: 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); } +.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(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-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(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-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-color); } -#search-box { display: none; align-items: center; gap: 8px; } -#search-box input { padding: 6px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; width: 180px; } -#search-box button, #modal-search-btn, #modal-close-btn { background: var(--user-border); color: white; border: none; border-radius: 6px; padding: 6px 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; } -#search-box button:hover, #modal-search-btn:hover { background: #1565c0; } -#modal-close-btn { background: var(--text-muted); margin-left: 8px; } -#modal-close-btn:hover { background: #616161; } -#search-modal[open] { border: none; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.2); 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.5); } -.search-modal-header { display: flex; align-items: center; gap: 8px; padding: 16px; border-bottom: 1px solid var(--assistant-border); background: var(--bg-color); border-radius: 12px 12px 0 0; } -.search-modal-header input { flex: 1; padding: 8px 12px; border: 1px solid var(--assistant-border); border-radius: 6px; font-size: 16px; } -#search-status { padding: 8px 16px; font-size: 0.85rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } -#search-results { flex: 1; overflow-y: auto; padding: 16px; } -.search-result { margin-bottom: 16px; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +.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(25, 118, 210, 0.05); } -.search-result-page { padding: 6px 12px; background: rgba(0,0,0,0.03); font-size: 0.8rem; color: var(--text-muted); border-bottom: 1px solid rgba(0,0,0,0.06); } -.search-result-content { padding: 12px; } -.search-result mark { background: #fff59d; padding: 1px 2px; border-radius: 2px; } -@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; } #search-box input { width: 120px; } #search-modal[open] { width: 95vw; height: 90vh; } } +.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; } +@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 = """ @@ -1555,6 +1664,8 @@ def render_message_with_tool_pairs( 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'; @@ -1611,13 +1722,65 @@ def render_message_with_tool_pairs( } }); }); -// Toggle between JSON and Markdown views for tool calls/results -document.querySelectorAll('.view-toggle-btn').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var container = btn.closest('.tool-use, .tool-result'); - container.classList.toggle('show-json'); - btn.textContent = container.classList.contains('show-json') ? 'Markdown' : 'JSON'; +// 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'); + } + }); + }); + }); }); }); """ diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index 138ec9d..9390723 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -46,9 +46,17 @@ {%- endif %} {% endmacro %} -{# Todo list #} -{% macro todo_list(todos, tool_id) %} -
    โ˜ฐ Task List
      +{# Todo list - input_json_html is pre-rendered JSON so needs |safe #} +{% macro todo_list(todos, input_json_html, tool_id) %} +
      +
      โ˜ฐ Task List +
      + + +
      +
      +
      +
        {%- for todo in todos -%} {%- set status = todo.status|default('pending') -%} {%- set content = todo.content|default('') -%} @@ -64,46 +72,81 @@ {%- endif -%}
      • {{ icon }}{{ content }}
      • {%- endfor -%} -
      +
    +
    + + {%- endmacro %} -{# Write tool - content is pre-highlighted so needs |safe #} -{% macro write_tool(file_path, content, tool_id) %} +{# Write tool - content is pre-highlighted so needs |safe, input_json_html is pre-rendered JSON so needs |safe #} +{% macro write_tool(file_path, content, input_json_html, tool_id) %} {%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%}
    -
    ๐Ÿ“ Write {{ filename }}
    +
    ๐Ÿ“ Write {{ filename }} +
    + + +
    +
    +
    {{ file_path }}
    {{ content|safe }}
    + +
    {%- endmacro %} -{# Edit tool - old/new strings are pre-highlighted so need |safe #} -{% macro edit_tool(file_path, old_string, new_string, replace_all, tool_id) %} +{# Edit tool - old/new strings are pre-highlighted so need |safe, input_json_html is pre-rendered JSON so needs |safe #} +{% macro edit_tool(file_path, old_string, new_string, replace_all, input_json_html, tool_id) %} {%- set filename = file_path.split('/')[-1] if '/' in file_path else file_path -%}
    -
    โœ๏ธ Edit {{ filename }}{% if replace_all %} (replace all){% endif %}
    +
    โœ๏ธ Edit {{ filename }}{% if replace_all %} (replace all){% endif %} +
    + + +
    +
    +
    {{ file_path }}
    โˆ’
    {{ old_string|safe }}
    +
    {{ new_string|safe }}
    + +
    {%- endmacro %} -{# Bash tool - description_html is pre-rendered markdown so needs |safe #} -{% macro bash_tool(command, description_html, tool_id) %} +{# Bash tool - description_html is pre-rendered markdown so needs |safe, input_json_html is pre-rendered JSON so needs |safe #} +{% macro bash_tool(command, description_html, input_json_html, tool_id) %}
    -
    โ†’ Call$ Bash
    +
    โ†’ Call$ Bash +
    + + +
    +
    +
    {%- if description_html %}
    {{ description_html|safe }}
    {%- endif -%}
    {{ command }}
    + +
    {%- endmacro %} {# Generic tool use - description_html, input_markdown_html, input_json_html are pre-rendered so need |safe #} {% macro tool_use(tool_name, description_html, input_markdown_html, input_json_html, tool_id) %} -
    โ†’ Callโš™ {{ tool_name }}
    +
    โ†’ Callโš™ {{ tool_name }}
    {%- if description_html -%}
    {{ description_html|safe }}
    {%- endif -%} @@ -113,7 +156,7 @@ {# Tool result - content_markdown_html and content_json_html are pre-rendered so need |safe #} {% macro tool_result(content_markdown_html, content_json_html, is_error) %} {%- set error_class = ' tool-error' if is_error else '' -%} -
    {% if is_error %}โœ— Error{% else %}โ† Result{% endif %}
    {{ content_markdown_html|safe }}
    {{ content_json_html|safe }}
    +
    {% if is_error %}โœ— Error{% else %}โ† Result{% endif %}
    {{ content_markdown_html|safe }}
    {{ content_json_html|safe }}
    {%- endmacro %} {# Tool pair wrapper - tool_use_html/tool_result_html are pre-rendered #} @@ -126,6 +169,12 @@
    {{ label }}{% if count %} ({{ count }}){% endif %} +{% if cell_type == 'tools' %} +
    + + +
    +{% endif %}
    {{ content_html|safe }}
    diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 9459cde..e2e7b8b 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -5,21 +5,102 @@ Claude Code transcript - Index @@ -594,6 +684,8 @@

    Claude Code transcript

    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'; @@ -650,13 +742,65 @@

    Claude Code transcript

    } }); }); -// Toggle between JSON and Markdown views for tool calls/results -document.querySelectorAll('.view-toggle-btn').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var container = btn.closest('.tool-use, .tool-result'); - container.classList.toggle('show-json'); - btn.textContent = container.classList.contains('show-json') ? 'Markdown' : 'JSON'; +// 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'); + } + }); + }); + }); }); }); diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index 82c8347..39f16de 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -5,21 +5,102 @@ Claude Code transcript - page 1 @@ -239,11 +329,19 @@

    Claude C
    -

    Create a simple Python function to add two numbers

    +
    + +Message + + + +

    Create a simple Python function to add two numbers

    +

    +

    Run pytest on tests directory

    python -m pytest tests/
    -
    โ† Result

    ===== test session starts ===== +

    + +
    โ† Result
    Tool Calls (1) + +
    + + +
    + + +
    +
    +

    Commit changes

    git add . && git commit -m 'Add math_utils with add function'
    -
    โ† Result
    1 file changed, 5 insertions(+)
    [main abc1234] Add math_utils with add function
    +
    + +
    โ† Result
    1 file changed, 5 insertions(+)
    [main abc1234] Add math_utils with add function
      1 file changed, 5 insertions(+)
    -

    Now edit the file to add a subtract function

    +
    + +Message + + + +

    Now edit the file to add a subtract function

    +
    Tool Calls (1) + +
    + + +
    +
    -
    โ†’ Callโš™ Glob
    { +
    โ†’ Callโš™ Glob
    { "pattern": */.py, "path": /project }
    {
       "pattern": "**/*.py",
       "path": "/project"
    -}
    โ† Result

    /project/math_utils.py +}

    โ† Result

    /project/math_utils.py /project/tests/test_math.py

    /project/math_utils.py
     /project/tests/test_math.py
    @@ -474,6 +712,12 @@

    Claude C
    Tool Calls (1) + +
    + + +
    +
    + +
    โ† Result

    File edited successfully

    File edited successfully
    -

    Run the tests again

    +
    + +Message + + + +

    Run the tests again

    +
    Tool Calls (1) + +
    + + +
    + + +
    +
    +

    Run tests with verbose output

    python -m pytest tests/ -v
    -
    โœ— Error

    Exit code 1 +

    + +
    โœ— Error

    Exit code 1 ===== FAILURES ===== test_subtract - AssertionError: expected 5 but got None

    Exit code 1
     ===== FAILURES =====
    @@ -567,6 +858,7 @@ 

    Claude C
    Response +

    -

    Fix the issue and commit

    +
    + +Message + + + +

    Fix the issue and commit

    +
    Tool Calls (1) + +
    + + +
    +
    -
    โœ๏ธ Edit test_math.py (replace all)
    +
    โœ๏ธ Edit test_math.py (replace all) +
    + + +
    +
    +
    /project/tests/test_math.py
    โˆ’
    assert subtract(10, 5) == None
    @@ -616,12 +927,27 @@ 

    Claude C
    +
    assert subtract(10, 5) == 5
     

    -
    +
    + +
    Tool Calls (1) + +
    + + +
    + + +
    +
    +

    Commit the fix

    git add . && git commit -m 'Add subtract function and fix tests'
    -
    โ† Result
    2 files changed, 10 insertions(+), 1 deletion(-)
    [main def5678] Add subtract function and fix tests
    +
    + +
    โ† Result
    2 files changed, 10 insertions(+), 1 deletion(-)
    [main def5678] Add subtract function and fix tests
      2 files changed, 10 insertions(+), 1 deletion(-)
    Response +

    Done! The subtract function is now working and committed.

    Session continuation summary
    -

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

    +
    + +Message + + + +

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

    +
    Response +
    @@ -324,6 +442,8 @@

    Claude C 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'; @@ -380,13 +500,65 @@

    Claude C } }); }); -// Toggle between JSON and Markdown views for tool calls/results -document.querySelectorAll('.view-toggle-btn').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var container = btn.closest('.tool-use, .tool-result'); - container.classList.toggle('show-json'); - btn.textContent = container.classList.contains('show-json') ? 'Markdown' : 'JSON'; +// 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'); + } + }); + }); + }); }); }); diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index deae124..f0ea717 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -5,21 +5,102 @@ Claude Code transcript - Index @@ -585,6 +675,8 @@

    Claude Code transcript

    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'; @@ -641,13 +733,65 @@

    Claude Code transcript

    } }); }); -// Toggle between JSON and Markdown views for tool calls/results -document.querySelectorAll('.view-toggle-btn').forEach(function(btn) { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - var container = btn.closest('.tool-use, .tool-result'); - container.classList.toggle('show-json'); - btn.textContent = container.classList.contains('show-json') ? 'Markdown' : 'JSON'; +// 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'); + } + }); + }); + }); }); }); diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html index 49d8749..33e75e1 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_block.html @@ -1,4 +1,4 @@ -
    โ† Result

    Command completed successfully +

    โ† Result

    Command completed successfully Output line 1 Output line 2

    Command completed successfully
     Output line 1
    diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html
    index 2db69ee..dc63dee 100644
    --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html
    +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array.html
    @@ -1,4 +1,4 @@
    -
    โ† Result
    +
    โ† Result

    Here is the file content:

    Line 1 Line 2

    [
    diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html
    index dbfc691..b8b592d 100644
    --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html
    +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_image.html
    @@ -1,4 +1,4 @@
    -
    โ† Result
    +
    โ† Result
    [
       {
         "type": "image",
    diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html
    index 119c55f..1448edc 100644
    --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html
    +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_content_block_array_with_tool_use.html
    @@ -1,7 +1,20 @@
    -
    โ† Result
    +
    โ† Result
    -
    โ†’ Call$ Bash
    +
    โ†’ Call$ Bash +
    + + +
    +
    +

    List files

    ls -la
    +
    +
    [
       {
         "type": "tool_use",
    diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html
    index 3095186..e408b89 100644
    --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html
    +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_error.html
    @@ -1,3 +1,3 @@
    -
    โœ— Error

    Error: file not found +

    โœ— Error

    Error: file not found Traceback follows...

    Error: file not found
     Traceback follows...
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html index 3c01f12..b3c21de 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html @@ -1,3 +1,3 @@ -
    โ† Result

    Tests passed: โœ“ All 5 tests passed +

    โ† Result

    Tests passed: โœ“ All 5 tests passed Error: None

    Tests passed: โœ“ All 5 tests passed
     Error: None
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html index aadeec1..5ac7262 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html +++ b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_commit.html @@ -1,2 +1,2 @@ -
    โ† Result
    2 files changed, 10 insertions(+)
    [main abc1234] Add new feature
    +
    โ† Result
    2 files changed, 10 insertions(+)
    [main abc1234] Add new feature
      2 files changed, 10 insertions(+)
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html index f45914d..61f2401 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_bash_tool.html @@ -1,5 +1,18 @@
    -
    โ†’ Call$ Bash
    +
    โ†’ Call$ Bash +
    + + +
    +
    +

    Run tests with verbose output

    pytest tests/ -v
    +
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html index 010c9d3..3ebd1c7 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool.html @@ -1,5 +1,11 @@
    -
    โœ๏ธ Edit file.py
    +
    โœ๏ธ Edit file.py +
    + + +
    +
    +
    /project/file.py
    โˆ’
    old code here
    @@ -7,4 +13,12 @@
     
    +
    new code here
     
    +
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html index 9ac42ff..18955e2 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_edit_tool_replace_all.html @@ -1,5 +1,11 @@
    -
    โœ๏ธ Edit file.py (replace all)
    +
    โœ๏ธ Edit file.py (replace all) +
    + + +
    +
    +
    /project/file.py
    โˆ’
    old
    @@ -7,4 +13,13 @@
     
    +
    new
     
    +
    +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html index 09177f0..9b31d32 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_todo_write.html @@ -1,2 +1,33 @@ -
    โ˜ฐ Task List
    • โœ“First task
    • โ†’Second task
    • โ—‹Third task
    \ No newline at end of file +
    +
    โ˜ฐ Task List +
    + + +
    +
    +
    +
    • โœ“First task
    • โ†’Second task
    • โ—‹Third task
    +
    + +
    \ No newline at end of file diff --git a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html index 8835c8a..252a86c 100644 --- a/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html +++ b/tests/__snapshots__/test_generate_html/TestRenderFunctions.test_render_write_tool.html @@ -1,7 +1,20 @@
    -
    ๐Ÿ“ Write main.py
    +
    ๐Ÿ“ Write main.py +
    + + +
    +
    +
    /project/src/main.py
    def hello():
         print('hello world')
     
    +
    +
    \ No newline at end of file From 379ef41b0897e8447e34b7e8bbaaa8204acbb92f Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Thu, 1 Jan 2026 02:56:38 -0500 Subject: [PATCH 58/62] Fix JSON display mode and tabs alignment regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON Display Fix: - Remove inline `style="display: none;"` from .view-json divs in macros.html - CSS class-based toggle (.show-json) now properly controls visibility - Affected: todo_list, write_tool, edit_tool, bash_tool macros Tabs Alignment Fix: - Update .cell summary to use flex: 1 on .cell-label instead of justify-content: space-between - Add flex-wrap: wrap to .tool-header, .file-tool-header, .todo-header for better responsiveness - Add gap: var(--spacing-sm) to .cell summary for consistent spacing ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/claude_code_transcripts/__init__.py | 9 ++++--- .../templates/macros.html | 8 +++--- ...enerateHtml.test_generates_index_html.html | 9 ++++--- ...rateHtml.test_generates_page_001_html.html | 27 ++++++++++--------- ...rateHtml.test_generates_page_002_html.html | 11 ++++---- ...SessionFile.test_jsonl_generates_html.html | 9 ++++--- ...ult_content_block_array_with_tool_use.html | 2 +- ...RenderFunctions.test_render_bash_tool.html | 2 +- ...RenderFunctions.test_render_edit_tool.html | 2 +- ...ons.test_render_edit_tool_replace_all.html | 2 +- ...enderFunctions.test_render_todo_write.html | 2 +- ...enderFunctions.test_render_write_tool.html | 2 +- 12 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 42e31bb..334e2b8 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1433,7 +1433,8 @@ def render_message_with_tool_pairs( .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; justify-content: space-between; 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); } +.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); } @@ -1458,7 +1459,7 @@ def render_message_with_tool_pairs( .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; } +.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); } .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; } @@ -1495,7 +1496,7 @@ def render_message_with_tool_pairs( .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); } +.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); } @@ -1515,7 +1516,7 @@ def render_message_with_tool_pairs( .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); } +.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; } diff --git a/src/claude_code_transcripts/templates/macros.html b/src/claude_code_transcripts/templates/macros.html index 9390723..05d673e 100644 --- a/src/claude_code_transcripts/templates/macros.html +++ b/src/claude_code_transcripts/templates/macros.html @@ -74,7 +74,7 @@ {%- endfor -%}
    - @@ -94,7 +94,7 @@
    {{ file_path }}
    {{ content|safe }}
    - @@ -117,7 +117,7 @@
    +
    {{ new_string|safe }}
    - @@ -138,7 +138,7 @@ {%- endif -%}
    {{ command }}
    - diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index e2e7b8b..0d94ca0 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -116,7 +116,8 @@ .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; justify-content: space-between; 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); } +.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); } @@ -141,7 +142,7 @@ .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; } +.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); } .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; } @@ -178,7 +179,7 @@ .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); } +.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); } @@ -198,7 +199,7 @@ .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); } +.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; } diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html index 39f16de..934ab9f 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html @@ -116,7 +116,8 @@ .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; justify-content: space-between; 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); } +.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); } @@ -141,7 +142,7 @@ .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; } +.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); } .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; } @@ -178,7 +179,7 @@ .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); } +.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); } @@ -198,7 +199,7 @@ .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); } +.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; } @@ -396,7 +397,7 @@

    Claude C return a + b

    -

    -
    +
    +
    Response @@ -466,7 +499,15 @@

    Claude C ===== 2 passed in 0.05s =====

    -
    +
    +
    Response @@ -564,7 +605,15 @@

    Claude C

    -
    +
    +
    Tool Calls (1) @@ -605,7 +654,15 @@

    Claude C

    1 file changed, 5 insertions(+)
    [main abc1234] Add math_utils with add function
      1 file changed, 5 insertions(+)
    -
    +
    +
    -
    +
    +
    - - -
    +
    +
    Response @@ -882,7 +992,14 @@

    Claude C return 42

    -
    +
    +
    Message @@ -891,7 +1008,15 @@

    Claude C

    Fix the issue and commit

    -
    -
    +
    +
    Tool Calls (1) @@ -980,7 +1113,14 @@

    Claude C

    2 files changed, 10 insertions(+), 1 deletion(-)
    [main def5678] Add subtract function and fix tests
      2 files changed, 10 insertions(+), 1 deletion(-)
    -
    +
    +
    Session continuation summary -
    +
    +
    Message diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html index 27b803c..182d4d8 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html @@ -143,7 +143,7 @@ .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); } +.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; } @@ -312,6 +312,16 @@ .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; } } @@ -329,7 +339,14 @@

    Claude C

    -
    +
    +
    Message @@ -338,7 +355,15 @@

    Claude C

    Add a multiply function too

    -
    -
    +
    +
    Response diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html index c8c44d9..7356d06 100644 --- a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -143,7 +143,7 @@ .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); } +.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; } @@ -312,6 +312,16 @@ .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; } } diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index e2ecfe1..f69f9fc 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -31,6 +31,7 @@ parse_session_file, get_session_summary, find_local_sessions, + calculate_message_metadata, ) @@ -351,6 +352,84 @@ def test_cell_copy_button_aria_label(self): assert 'aria-label="Copy Tool Calls"' in result +class TestMessageMetadata: + """Tests for message metadata calculation and rendering.""" + + def test_calculate_metadata_string_content(self): + """Test metadata calculation for string content.""" + message_data = {"content": "Hello, world!"} + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 13 + assert metadata["token_estimate"] == 3 # 13 // 4 = 3 + assert metadata["tool_counts"] == {} + + def test_calculate_metadata_text_blocks(self): + """Test metadata calculation for text blocks.""" + message_data = { + "content": [ + {"type": "text", "text": "Hello!"}, # 6 chars + {"type": "text", "text": "World!"}, # 6 chars + ] + } + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 12 + assert metadata["token_estimate"] == 3 # 12 // 4 = 3 + assert metadata["tool_counts"] == {} + + def test_calculate_metadata_thinking_blocks(self): + """Test metadata includes thinking block content.""" + message_data = { + "content": [ + {"type": "thinking", "thinking": "Let me think..."}, # 15 chars + ] + } + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 15 + assert metadata["token_estimate"] == 3 # 15 // 4 = 3 + + def test_calculate_metadata_tool_counts(self): + """Test tool counting in metadata.""" + message_data = { + "content": [ + {"type": "tool_use", "name": "Bash", "input": {}, "id": "t1"}, + {"type": "tool_use", "name": "Bash", "input": {}, "id": "t2"}, + {"type": "tool_use", "name": "Read", "input": {}, "id": "t3"}, + ] + } + metadata = calculate_message_metadata(message_data) + assert metadata["tool_counts"] == {"Bash": 2, "Read": 1} + + def test_calculate_metadata_empty_content(self): + """Test metadata for empty content.""" + message_data = {"content": ""} + metadata = calculate_message_metadata(message_data) + assert metadata["char_count"] == 0 + assert metadata["token_estimate"] == 0 + assert metadata["tool_counts"] == {} + + def test_metadata_in_rendered_message(self, output_dir): + """Test that metadata section appears in rendered messages.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + assert 'class="message-metadata"' in page_html + assert 'class="metadata-content"' in page_html + assert 'class="metadata-label"' in page_html + assert 'class="metadata-value"' in page_html + + def test_metadata_css_present(self, output_dir): + """Test that metadata CSS classes are defined.""" + fixture_path = Path(__file__).parent / "sample_session.json" + generate_html(fixture_path, output_dir, github_repo="example/project") + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + assert ".message-metadata" in page_html + assert ".metadata-item" in page_html + assert ".metadata-label" in page_html + assert ".metadata-value" in page_html + + class TestRenderContentBlock: """Tests for render_content_block function.""" From 9a877f2e78ff0601131a88b82bbab79717bf18f5 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Mon, 5 Jan 2026 20:43:16 -0500 Subject: [PATCH 60/62] Fix duplicate test method by renaming to unique name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test file had two methods named `test_tool_result_with_ansi_codes` at lines 512 and 545 in TestRenderContentBlock class. The second one was silently overriding the first. - Renamed second test to `test_tool_result_with_ansi_codes_snapshot` - Updated docstring to clarify the test's purpose - Renamed snapshot file to match new test name - Both tests now run correctly (5 ANSI-related tests pass) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...tBlock.test_tool_result_with_ansi_codes_snapshot.html} | 0 tests/test_generate_html.py | 8 ++++++-- 2 files changed, 6 insertions(+), 2 deletions(-) rename tests/__snapshots__/test_generate_html/{TestRenderContentBlock.test_tool_result_with_ansi_codes.html => TestRenderContentBlock.test_tool_result_with_ansi_codes_snapshot.html} (100%) diff --git a/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html b/tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes_snapshot.html similarity index 100% rename from tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes.html rename to tests/__snapshots__/test_generate_html/TestRenderContentBlock.test_tool_result_with_ansi_codes_snapshot.html diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index f69f9fc..7035b57 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -542,8 +542,12 @@ def test_tool_result_with_commit(self, snapshot_html): finally: claude_code_transcripts._github_repo = old_repo - def test_tool_result_with_ansi_codes(self, snapshot_html): - """Test that ANSI escape codes are stripped from tool results.""" + def test_tool_result_with_ansi_codes_snapshot(self, snapshot_html): + """Test ANSI escape code stripping with snapshot comparison. + + This is a snapshot test companion to test_tool_result_with_ansi_codes + that verifies the complete HTML output structure. + """ block = { "type": "tool_result", "content": "\x1b[38;2;166;172;186mTests passed:\x1b[0m \x1b[32mโœ“\x1b[0m All 5 tests passed\n\x1b[1;31mError:\x1b[0m None", From 26acdcdb5113b87d03540ab9c4940303ca8d99a5 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Mon, 5 Jan 2026 20:45:47 -0500 Subject: [PATCH 61/62] Refactor _github_repo to thread-safe contextvars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module-level _github_repo variable posed a thread-safety risk when processing multiple sessions concurrently. This refactors to use Python's contextvars module which provides thread-local storage. Changes: - Add contextvars import - Create _github_repo_var ContextVar with None default - Add get_github_repo() accessor function (thread-safe) - Add set_github_repo() setter function (thread-safe) - Keep _github_repo module variable for backward compatibility - Update all internal usages to use new functions - Update test to use new API The backward-compatible design ensures existing code that reads _github_repo directly continues to work. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/claude_code_transcripts/__init__.py | 61 +++++++++++++++++++++---- tests/test_generate_html.py | 9 ++-- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index dd034e5..a54386a 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1,5 +1,6 @@ """Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination.""" +import contextvars import json import html import os @@ -261,9 +262,49 @@ def extract_text_from_content(content): return "" -# Module-level variable for GitHub repo (set by generate_html) +# Thread-safe context variable for GitHub repo (set by generate_html) +# Using contextvars ensures thread-safety when processing multiple sessions concurrently +_github_repo_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "_github_repo", default=None +) + +# Backward compatibility: module-level variable that tests may still access +# This is deprecated - use get_github_repo() and set_github_repo() instead _github_repo = None + +def get_github_repo() -> str | None: + """Get the current GitHub repo from the thread-local context. + + This is the thread-safe way to access the GitHub repo setting. + Falls back to the module-level _github_repo for backward compatibility. + + Returns: + The GitHub repository in 'owner/repo' format, or None if not set. + """ + ctx_value = _github_repo_var.get() + if ctx_value is not None: + return ctx_value + # Fallback for backward compatibility + return _github_repo + + +def set_github_repo(repo: str | None) -> contextvars.Token[str | None]: + """Set the GitHub repo in the thread-local context. + + This is the thread-safe way to set the GitHub repo. Also updates + the module-level _github_repo for backward compatibility. + + Args: + repo: The GitHub repository in 'owner/repo' format, or None. + + Returns: + A token that can be used to reset the value later. + """ + global _github_repo + _github_repo = repo + return _github_repo_var.set(repo) + # API constants API_BASE_URL = "https://api.anthropic.com/v1" ANTHROPIC_VERSION = "2023-06-01" @@ -1016,7 +1057,9 @@ def render_content_block(block): commit_hash = match.group(1) commit_msg = match.group(2) parts.append( - _macros.commit_card(commit_hash, commit_msg, _github_repo) + _macros.commit_card( + commit_hash, commit_msg, get_github_repo() + ) ) last_end = match.end() @@ -2035,9 +2078,8 @@ def generate_html(json_path, output_dir, github_repo=None): "Warning: Could not auto-detect GitHub repo. Commit links will be disabled." ) - # Set module-level variable for render functions - global _github_repo - _github_repo = github_repo + # Set thread-safe context variable for render functions + set_github_repo(github_repo) conversations = [] current_conv = None @@ -2191,7 +2233,7 @@ def generate_html(json_path, output_dir, github_repo=None): # Add commits as separate timeline items for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: item_html = _macros.index_commit( - commit_hash, commit_msg, commit_ts, _github_repo + commit_hash, commit_msg, commit_ts, get_github_repo() ) timeline_items.append((commit_ts, "commit", item_html)) @@ -2533,9 +2575,8 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): if github_repo: click.echo(f"Auto-detected GitHub repo: {github_repo}") - # Set module-level variable for render functions - global _github_repo - _github_repo = github_repo + # Set thread-safe context variable for render functions + set_github_repo(github_repo) conversations = [] current_conv = None @@ -2689,7 +2730,7 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): # Add commits as separate timeline items for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: item_html = _macros.index_commit( - commit_hash, commit_msg, commit_ts, _github_repo + commit_hash, commit_msg, commit_ts, get_github_repo() ) timeline_items.append((commit_ts, "commit", item_html)) diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 7035b57..f8e29d9 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -526,11 +526,12 @@ def test_tool_result_with_ansi_codes(self): def test_tool_result_with_commit(self, snapshot_html): """Test tool result with git commit output.""" - # Need to set the global _github_repo for commit link rendering + # Need to set the github repo for commit link rendering + # Using the thread-safe set_github_repo function import claude_code_transcripts - old_repo = claude_code_transcripts._github_repo - claude_code_transcripts._github_repo = "example/repo" + old_repo = claude_code_transcripts.get_github_repo() + claude_code_transcripts.set_github_repo("example/repo") try: block = { "type": "tool_result", @@ -540,7 +541,7 @@ def test_tool_result_with_commit(self, snapshot_html): result = render_content_block(block) assert result == snapshot_html finally: - claude_code_transcripts._github_repo = old_repo + claude_code_transcripts.set_github_repo(old_repo) def test_tool_result_with_ansi_codes_snapshot(self, snapshot_html): """Test ANSI escape code stripping with snapshot comparison. From ab65c8d45a73de548e36e17b9654f0f493ebc3d2 Mon Sep 17 00:00:00 2001 From: ShlomoStept Date: Mon, 5 Jan 2026 20:48:41 -0500 Subject: [PATCH 62/62] Add Clipboard API fallback for older browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a copyToClipboard() helper function that: 1. Uses the modern Clipboard API (navigator.clipboard.writeText) when available 2. Falls back to document.execCommand('copy') for older browsers 3. Returns a Promise for consistent handling Also added user-facing error feedback: - Button now shows "Failed" for 2 seconds on copy failure - Improves UX by informing users when copy doesn't work Updated both copy button implementations: - Dynamically added copy buttons on pre/tool-result elements - Cell header copy buttons ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/claude_code_transcripts/__init__.py | 36 +++++++++++++++++-- ...enerateHtml.test_generates_index_html.html | 35 ++++++++++++++++-- ...rateHtml.test_generates_page_001_html.html | 35 ++++++++++++++++-- ...rateHtml.test_generates_page_002_html.html | 35 ++++++++++++++++-- ...SessionFile.test_jsonl_generates_html.html | 35 ++++++++++++++++-- 5 files changed, 166 insertions(+), 10 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index a54386a..def9d6a 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -305,6 +305,7 @@ def set_github_repo(repo: str | None) -> contextvars.Token[str | None]: _github_repo = repo return _github_repo_var.set(repo) + # API constants API_BASE_URL = "https://api.anthropic.com/v1" ANTHROPIC_VERSION = "2023-06-01" @@ -1800,6 +1801,33 @@ def render_message_with_tool_pairs( """ 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); @@ -1844,7 +1872,7 @@ def render_message_with_tool_pairs( copyBtn.addEventListener('click', function(e) { e.stopPropagation(); const textToCopy = el.textContent.replace(/^Copy$/, '').trim(); - navigator.clipboard.writeText(textToCopy).then(function() { + copyToClipboard(textToCopy).then(function() { copyBtn.textContent = 'Copied!'; copyBtn.classList.add('copied'); setTimeout(function() { @@ -1853,6 +1881,8 @@ def render_message_with_tool_pairs( }, 2000); }).catch(function(err) { console.error('Failed to copy:', err); + copyBtn.textContent = 'Failed'; + setTimeout(function() { copyBtn.textContent = 'Copy'; }, 2000); }); }); el.appendChild(copyBtn); @@ -1871,7 +1901,7 @@ def render_message_with_tool_pairs( const content = cell.querySelector('.cell-content'); textToCopy = content.textContent.trim(); } - navigator.clipboard.writeText(textToCopy).then(function() { + copyToClipboard(textToCopy).then(function() { btn.textContent = 'Copied!'; btn.classList.add('copied'); setTimeout(function() { @@ -1880,6 +1910,8 @@ def render_message_with_tool_pairs( }, 2000); }).catch(function(err) { console.error('Failed to copy cell:', err); + btn.textContent = 'Failed'; + setTimeout(function() { btn.textContent = 'Copy'; }, 2000); }); }); // Keyboard accessibility diff --git a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html index 605adc5..72f000e 100644 --- a/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html +++ b/tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html @@ -663,6 +663,33 @@

    Claude Code transcript