claude-code-log 0.2.4__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/.claude/settings.local.json +2 -1
  2. claude_code_log-0.2.6/.github/workflows/docs.yml +43 -0
  3. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/.gitignore +1 -0
  4. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/CHANGELOG.md +20 -1
  5. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/PKG-INFO +12 -4
  6. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/README.md +11 -3
  7. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/converter.py +59 -2
  8. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/parser.py +1 -2
  9. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/renderer.py +179 -36
  10. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/templates/index.html +32 -10
  11. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/templates/transcript.html +123 -8
  12. claude_code_log-0.2.6/justfile +147 -0
  13. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/pyproject.toml +4 -1
  14. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/uv.lock +1 -1
  15. claude_code_log-0.2.4/justfile +0 -59
  16. claude_code_log-0.2.4/scripts/generate_style_guide.py +0 -517
  17. claude_code_log-0.2.4/scripts/style_guide_output/index.html +0 -103
  18. claude_code_log-0.2.4/scripts/style_guide_output/index_style_guide.html +0 -159
  19. claude_code_log-0.2.4/scripts/style_guide_output/transcript_style_guide.html +0 -430
  20. claude_code_log-0.2.4/test/README.md +0 -204
  21. claude_code_log-0.2.4/test/__init__.py +0 -0
  22. claude_code_log-0.2.4/test/test_command_handling.py +0 -66
  23. claude_code_log-0.2.4/test/test_data/edge_cases.jsonl +0 -19
  24. claude_code_log-0.2.4/test/test_data/representative_messages.jsonl +0 -12
  25. claude_code_log-0.2.4/test/test_data/session_b.jsonl +0 -3
  26. claude_code_log-0.2.4/test/test_data/todowrite_examples.jsonl +0 -12
  27. claude_code_log-0.2.4/test/test_date_filtering.py +0 -173
  28. claude_code_log-0.2.4/test/test_filtering.py +0 -150
  29. claude_code_log-0.2.4/test/test_markdown_rendering.py +0 -128
  30. claude_code_log-0.2.4/test/test_message_filtering.py +0 -179
  31. claude_code_log-0.2.4/test/test_message_types.py +0 -67
  32. claude_code_log-0.2.4/test/test_path_conversion.py +0 -38
  33. claude_code_log-0.2.4/test/test_template_data.py +0 -328
  34. claude_code_log-0.2.4/test/test_template_rendering.py +0 -329
  35. claude_code_log-0.2.4/test/test_template_utils.py +0 -245
  36. claude_code_log-0.2.4/test/test_todowrite_rendering.py +0 -335
  37. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/.github/workflows/ci.yml +0 -0
  38. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/CLAUDE.md +0 -0
  39. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/LICENSE +0 -0
  40. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/__init__.py +0 -0
  41. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/cli.py +0 -0
  42. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/models.py +0 -0
  43. {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/py.typed +0 -0
@@ -29,7 +29,8 @@
29
29
  "Bash(open /Users/dain/workspace/claude-code-log/scripts/style_guide_output/transcript_style_guide.html)",
30
30
  "Bash(open /tmp/test_preview.html)",
31
31
  "Bash(open /tmp/test_improved_preview.html)",
32
- "Bash(open /tmp/test_fixed_preview.html)"
32
+ "Bash(open /tmp/test_fixed_preview.html)",
33
+ "Bash(git tag:*)"
33
34
  ],
34
35
  "deny": []
35
36
  }
@@ -0,0 +1,43 @@
1
+ # Simple workflow for deploying static content to GitHub Pages
2
+ name: Deploy static content to Pages
3
+
4
+ on:
5
+ # Runs on pushes targeting the default branch
6
+ push:
7
+ branches: ["main"]
8
+
9
+ # Allows you to run this workflow manually from the Actions tab
10
+ workflow_dispatch:
11
+
12
+ # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
13
+ permissions:
14
+ contents: read
15
+ pages: write
16
+ id-token: write
17
+
18
+ # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
19
+ # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
20
+ concurrency:
21
+ group: "pages"
22
+ cancel-in-progress: false
23
+
24
+ jobs:
25
+ # Single deploy job since we're just deploying
26
+ deploy:
27
+ environment:
28
+ name: github-pages
29
+ url: ${{ steps.deployment.outputs.page_url }}
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - name: Checkout
33
+ uses: actions/checkout@v4
34
+ - name: Setup Pages
35
+ uses: actions/configure-pages@v5
36
+ - name: Upload artifact
37
+ uses: actions/upload-pages-artifact@v3
38
+ with:
39
+ # Upload docs
40
+ path: 'docs'
41
+ - name: Deploy to GitHub Pages
42
+ id: deployment
43
+ uses: actions/deploy-pages@v4
@@ -177,3 +177,4 @@ cython_debug/
177
177
  test_output
178
178
  .DS_Store
179
179
  test/test_data/*.html
180
+ .claude-trace
@@ -5,11 +5,30 @@ All notable changes to claude-code-log will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+
9
+ ## [0.2.6] - 2025-06-20
10
+
11
+ ### Changed
12
+
13
+ - **Token usage stats and usage time intervals on top level index page + make time consistently UTC**
14
+ - **Fix example transcript link + exclude dirs from package**
15
+
16
+
17
+ ## [0.2.5] - 2025-06-18
18
+
19
+ ### Changed
20
+
21
+ - **Tiny Justfile fixes**
22
+ - **Create docs.yml**
23
+ - **Improve expandable details handling + open/close all button + just render short ones + add example**
24
+ - **Remove unnecessary line in error message**
25
+ - **Script release process**
26
+
8
27
  ## [0.2.4] - 2025-06-18
9
28
 
10
29
  ### Changed
11
30
 
12
- - **More rrror handling**: Add better error reporting with line numbers and render fallbacks
31
+ - **More error handling**: Add better error reporting with line numbers and render fallbacks
13
32
 
14
33
  ## [0.2.3] - 2025-06-16
15
34
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-log
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Convert Claude Code transcript JSONL files to HTML
5
5
  Project-URL: Homepage, https://github.com/daaain/claude-code-log
6
6
  Project-URL: Issues, https://github.com/daaain/claude-code-log/issues
@@ -29,6 +29,8 @@ A Python CLI tool that converts Claude Code transcript JSONL files into readable
29
29
 
30
30
  This tool generates clean, minimalist HTML pages showing user prompts and assistant responses chronologically. It's designed to create a readable log of your Claude Code interactions with support for both individual files and entire project hierarchies.
31
31
 
32
+ [See example log (generated from real usage on this project).](https://daaain.github.io/claude-code-log/claude-code-log-transcript.html)
33
+
32
34
  ## Quickstart
33
35
 
34
36
  TL;DR: run the command below and browse the pages generated from your entire Claude Code archives:
@@ -233,7 +235,13 @@ uv run claude-code-log
233
235
 
234
236
  ## TODO
235
237
 
236
- - **Show top level stats on index page**: token usage added up + time of last interaction
237
- - **Tool Use Preview**: Show first few lines of tool use and other collapsed details
238
238
  - **In-page Filtering**: Client-side filtering and search
239
- - **Timeline view**: Show interaction on a timeline to get a better idea on timings and parallel calls
239
+ - **Timeline view**: Show interaction on a timeline to get a better idea on timings and parallel calls - maybe Timeline.js optionally generated runtime?
240
+ - document what questions does it help answering
241
+ - filter out system message from beginning of convo to show on session navigation
242
+ - make tool results messages be computer rather than user – or even just remove the assistant / user box around or move the tool call / result to the top?
243
+ - integrate claude-trace request logs if present?
244
+ - use Anthropic's own types: <https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/types> – can these be used to generate Pydantic classes though?
245
+ - Shortcut / command to resume a specific conversation by session ID $ claude --resume 550e8400-e29b-41d4-a716-446655440000?
246
+ - Split up transcripts by jsonl files (too) as the combined ones can get quite big and add navigation to the top level
247
+ - Localised number formatting and timezone adjustment runtime?
@@ -10,6 +10,8 @@ A Python CLI tool that converts Claude Code transcript JSONL files into readable
10
10
 
11
11
  This tool generates clean, minimalist HTML pages showing user prompts and assistant responses chronologically. It's designed to create a readable log of your Claude Code interactions with support for both individual files and entire project hierarchies.
12
12
 
13
+ [See example log (generated from real usage on this project).](https://daaain.github.io/claude-code-log/claude-code-log-transcript.html)
14
+
13
15
  ## Quickstart
14
16
 
15
17
  TL;DR: run the command below and browse the pages generated from your entire Claude Code archives:
@@ -214,7 +216,13 @@ uv run claude-code-log
214
216
 
215
217
  ## TODO
216
218
 
217
- - **Show top level stats on index page**: token usage added up + time of last interaction
218
- - **Tool Use Preview**: Show first few lines of tool use and other collapsed details
219
219
  - **In-page Filtering**: Client-side filtering and search
220
- - **Timeline view**: Show interaction on a timeline to get a better idea on timings and parallel calls
220
+ - **Timeline view**: Show interaction on a timeline to get a better idea on timings and parallel calls - maybe Timeline.js optionally generated runtime?
221
+ - document what questions does it help answering
222
+ - filter out system message from beginning of convo to show on session navigation
223
+ - make tool results messages be computer rather than user – or even just remove the assistant / user box around or move the tool call / result to the top?
224
+ - integrate claude-trace request logs if present?
225
+ - use Anthropic's own types: <https://github.com/anthropics/anthropic-sdk-python/tree/main/src/anthropic/types> – can these be used to generate Pydantic classes though?
226
+ - Shortcut / command to resume a specific conversation by session ID $ claude --resume 550e8400-e29b-41d4-a716-446655440000?
227
+ - Split up transcripts by jsonl files (too) as the combined ones can get quite big and add navigation to the top level
228
+ - Localised number formatting and timezone adjustment runtime?
@@ -97,6 +97,57 @@ def process_projects_hierarchy(
97
97
  max(f.stat().st_mtime for f in jsonl_files) if jsonl_files else 0.0
98
98
  )
99
99
 
100
+ # Calculate token usage aggregation and find first/last interaction timestamps
101
+ total_input_tokens = 0
102
+ total_output_tokens = 0
103
+ total_cache_creation_tokens = 0
104
+ total_cache_read_tokens = 0
105
+ latest_timestamp = ""
106
+ earliest_timestamp = ""
107
+
108
+ # Track requestIds to avoid double-counting tokens
109
+ seen_request_ids: set[str] = set()
110
+
111
+ for message in messages:
112
+ # Track latest and earliest timestamps across all messages
113
+ if hasattr(message, "timestamp"):
114
+ message_timestamp = getattr(message, "timestamp", "")
115
+ if message_timestamp:
116
+ # Track latest timestamp
117
+ if not latest_timestamp or message_timestamp > latest_timestamp:
118
+ latest_timestamp = message_timestamp
119
+
120
+ # Track earliest timestamp
121
+ if (
122
+ not earliest_timestamp
123
+ or message_timestamp < earliest_timestamp
124
+ ):
125
+ earliest_timestamp = message_timestamp
126
+
127
+ # Calculate token usage for assistant messages
128
+ if message.type == "assistant" and hasattr(message, "message"):
129
+ assistant_message = getattr(message, "message")
130
+ request_id = getattr(message, "requestId", None)
131
+
132
+ if (
133
+ hasattr(assistant_message, "usage")
134
+ and assistant_message.usage
135
+ and request_id
136
+ and request_id not in seen_request_ids
137
+ ):
138
+ # Mark requestId as seen to avoid double-counting
139
+ seen_request_ids.add(request_id)
140
+
141
+ usage = assistant_message.usage
142
+ total_input_tokens += usage.input_tokens
143
+ total_output_tokens += usage.output_tokens
144
+ if usage.cache_creation_input_tokens:
145
+ total_cache_creation_tokens += (
146
+ usage.cache_creation_input_tokens
147
+ )
148
+ if usage.cache_read_input_tokens:
149
+ total_cache_read_tokens += usage.cache_read_input_tokens
150
+
100
151
  project_summaries.append(
101
152
  {
102
153
  "name": project_dir.name,
@@ -105,13 +156,19 @@ def process_projects_hierarchy(
105
156
  "jsonl_count": jsonl_count,
106
157
  "message_count": len(messages),
107
158
  "last_modified": last_modified,
159
+ "total_input_tokens": total_input_tokens,
160
+ "total_output_tokens": total_output_tokens,
161
+ "total_cache_creation_tokens": total_cache_creation_tokens,
162
+ "total_cache_read_tokens": total_cache_read_tokens,
163
+ "latest_timestamp": latest_timestamp,
164
+ "earliest_timestamp": earliest_timestamp,
108
165
  }
109
166
  )
110
167
  except Exception as e:
111
168
  print(
112
169
  f"Warning: Failed to process {project_dir}: {e}\n"
113
- f"Previous (in alphabetical order) file before error: {project_summaries[-1]}\n"
114
- f"Traceback:\n{traceback.format_exc()}"
170
+ f"Previous (in alphabetical order) file before error: {project_summaries[-1]}"
171
+ f"\n{traceback.format_exc()}"
115
172
  )
116
173
  continue
117
174
 
@@ -103,6 +103,7 @@ def load_transcript(jsonl_path: Path) -> List[TranscriptEntry]:
103
103
  messages: List[TranscriptEntry] = []
104
104
 
105
105
  with open(jsonl_path, "r", encoding="utf-8") as f:
106
+ print(f"Processing {jsonl_path}...")
106
107
  for line_no, line in enumerate(f):
107
108
  line = line.strip()
108
109
  if line:
@@ -149,8 +150,6 @@ def load_transcript(jsonl_path: Path) -> List[TranscriptEntry]:
149
150
  "\n{traceback.format_exc()}"
150
151
  )
151
152
 
152
- print()
153
-
154
153
  return messages
155
154
 
156
155
 
@@ -24,9 +24,13 @@ from .parser import extract_text_content
24
24
 
25
25
 
26
26
  def format_timestamp(timestamp_str: str) -> str:
27
- """Format ISO timestamp for display."""
27
+ """Format ISO timestamp for display, converting to UTC."""
28
28
  try:
29
29
  dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
30
+ # Convert to UTC if timezone-aware
31
+ if dt.tzinfo is not None:
32
+ dt = dt.utctimetuple()
33
+ dt = datetime(*dt[:6]) # Convert back to naive datetime in UTC
30
34
  return dt.strftime("%Y-%m-%d %H:%M:%S")
31
35
  except (ValueError, AttributeError):
32
36
  return timestamp_str
@@ -37,6 +41,41 @@ def escape_html(text: str) -> str:
37
41
  return html.escape(text)
38
42
 
39
43
 
44
+ def create_collapsible_details(
45
+ summary: str, content: str, css_classes: str = ""
46
+ ) -> str:
47
+ """Create a collapsible details element with consistent styling and preview functionality."""
48
+ class_attr = ' class="collapsible-details"'
49
+ wrapper_classes = f"tool-content{' ' + css_classes if css_classes else ''}"
50
+
51
+ if len(content) <= 200:
52
+ return f"""
53
+ <div class="{wrapper_classes}">
54
+ {summary}
55
+ <div class="details-content">
56
+ {content}
57
+ </div>
58
+ </div>
59
+ """
60
+
61
+ # Get first ~200 characters, break at word boundaries
62
+ preview_text = content[:200] + "..."
63
+
64
+ return f"""
65
+ <div class="{wrapper_classes}">
66
+ <details{class_attr}>
67
+ <summary>
68
+ {summary}
69
+ <div class="preview-content">{preview_text}</div>
70
+ </summary>
71
+ <div class="details-content">
72
+ {content}
73
+ </div>
74
+ </details>
75
+ </div>
76
+ """
77
+
78
+
40
79
  def render_markdown(text: str) -> str:
41
80
  """Convert markdown text to HTML using mistune."""
42
81
  # Configure mistune with GitHub-flavored markdown features
@@ -181,17 +220,10 @@ def format_tool_use_content(tool_use: ToolUseContent) -> str:
181
220
  except (TypeError, ValueError):
182
221
  escaped_input = escape_html(str(tool_use.input))
183
222
 
184
- return f"""
185
- <div class="tool-content tool-use">
186
- <details>
187
- <summary><strong>🛠️ Tool Use:</strong> {escaped_name} (ID: {escaped_id})</summary>
188
- <div class="tool-input">
189
- <strong>Input:</strong>
190
- <pre>{escaped_input}</pre>
191
- </div>
192
- </details>
193
- </div>
194
- """
223
+ summary = f"<strong>🛠️ Tool Use:</strong> {escaped_name} (ID: {escaped_id})"
224
+ content = f"<pre>{escaped_input}</pre>"
225
+
226
+ return create_collapsible_details(summary, content, "tool-use")
195
227
 
196
228
 
197
229
  def format_tool_result_content(tool_result: ToolResultContent) -> str:
@@ -206,37 +238,27 @@ def format_tool_result_content(tool_result: ToolResultContent) -> str:
206
238
  content_parts: List[str] = []
207
239
  for item in tool_result.content:
208
240
  if item.get("type") == "text":
209
- content_parts.append(item.get("text", ""))
241
+ text_value = item.get("text")
242
+ if isinstance(text_value, str):
243
+ content_parts.append(text_value)
210
244
  escaped_content = escape_html("\n".join(content_parts))
211
245
 
212
246
  error_indicator = " (🚨 Error)" if tool_result.is_error else ""
213
247
 
214
- return f"""
215
- <div class="tool-content tool-result">
216
- <details>
217
- <summary><strong>🧰 Tool Result{error_indicator}:</strong> {escaped_id}</summary>
218
- <div class="tool-input">
219
- <pre>{escaped_content}</pre>
220
- </div>
221
- </details>
222
- </div>
223
- """
248
+ summary = f"<strong>🧰 Tool Result{error_indicator}:</strong> {escaped_id}"
249
+ content = f"<pre>{escaped_content}</pre>"
250
+
251
+ return create_collapsible_details(summary, content, "tool-result")
224
252
 
225
253
 
226
254
  def format_thinking_content(thinking: ThinkingContent) -> str:
227
255
  """Format thinking content as HTML."""
228
256
  escaped_thinking = escape_html(thinking.thinking)
229
257
 
230
- return f"""
231
- <div class="tool-content thinking-content">
232
- <details>
233
- <summary><strong>💭 Thinking</strong></summary>
234
- <div class="thinking-text">
235
- <pre>{escaped_thinking}</pre>
236
- </div>
237
- </details>
238
- </div>
239
- """
258
+ summary = "<strong>💭 Thinking</strong>"
259
+ content = f'<div class="thinking-text">{escaped_thinking}</div>'
260
+
261
+ return create_collapsible_details(summary, content, "thinking-content")
240
262
 
241
263
 
242
264
  def format_image_content(image: ImageContent) -> str:
@@ -340,6 +362,14 @@ class TemplateProject:
340
362
  self.jsonl_count = project_data["jsonl_count"]
341
363
  self.message_count = project_data["message_count"]
342
364
  self.last_modified = project_data["last_modified"]
365
+ self.total_input_tokens = project_data.get("total_input_tokens", 0)
366
+ self.total_output_tokens = project_data.get("total_output_tokens", 0)
367
+ self.total_cache_creation_tokens = project_data.get(
368
+ "total_cache_creation_tokens", 0
369
+ )
370
+ self.total_cache_read_tokens = project_data.get("total_cache_read_tokens", 0)
371
+ self.latest_timestamp = project_data.get("latest_timestamp", "")
372
+ self.earliest_timestamp = project_data.get("earliest_timestamp", "")
343
373
 
344
374
  # Format display name (remove leading dash and convert dashes to slashes)
345
375
  self.display_name = self.name
@@ -348,7 +378,46 @@ class TemplateProject:
348
378
 
349
379
  # Format last modified date
350
380
  last_modified_dt = datetime.fromtimestamp(self.last_modified)
351
- self.formatted_date = last_modified_dt.strftime("%Y-%m-%d %H:%M")
381
+ self.formatted_date = last_modified_dt.strftime("%Y-%m-%d %H:%M:%S")
382
+
383
+ # Format interaction time range
384
+ if self.earliest_timestamp and self.latest_timestamp:
385
+ if self.earliest_timestamp == self.latest_timestamp:
386
+ # Single interaction
387
+ self.formatted_time_range = format_timestamp(self.latest_timestamp)
388
+ else:
389
+ # Time range
390
+ earliest_formatted = format_timestamp(self.earliest_timestamp)
391
+ latest_formatted = format_timestamp(self.latest_timestamp)
392
+ self.formatted_time_range = (
393
+ f"{earliest_formatted} to {latest_formatted}"
394
+ )
395
+ elif self.latest_timestamp:
396
+ self.formatted_time_range = format_timestamp(self.latest_timestamp)
397
+ else:
398
+ self.formatted_time_range = ""
399
+
400
+ # Format last interaction timestamp (kept for backward compatibility)
401
+ if self.latest_timestamp:
402
+ self.formatted_last_interaction = format_timestamp(self.latest_timestamp)
403
+ else:
404
+ self.formatted_last_interaction = ""
405
+
406
+ # Format token usage
407
+ self.token_summary = ""
408
+ if self.total_input_tokens > 0 or self.total_output_tokens > 0:
409
+ token_parts: List[str] = []
410
+ if self.total_input_tokens > 0:
411
+ token_parts.append(f"Input: {self.total_input_tokens}")
412
+ if self.total_output_tokens > 0:
413
+ token_parts.append(f"Output: {self.total_output_tokens}")
414
+ if self.total_cache_creation_tokens > 0:
415
+ token_parts.append(
416
+ f"Cache Creation: {self.total_cache_creation_tokens}"
417
+ )
418
+ if self.total_cache_read_tokens > 0:
419
+ token_parts.append(f"Cache Read: {self.total_cache_read_tokens}")
420
+ self.token_summary = " | ".join(token_parts)
352
421
 
353
422
 
354
423
  class TemplateSummary:
@@ -359,6 +428,79 @@ class TemplateSummary:
359
428
  self.total_jsonl = sum(p["jsonl_count"] for p in project_summaries)
360
429
  self.total_messages = sum(p["message_count"] for p in project_summaries)
361
430
 
431
+ # Calculate aggregated token usage
432
+ self.total_input_tokens = sum(
433
+ p.get("total_input_tokens", 0) for p in project_summaries
434
+ )
435
+ self.total_output_tokens = sum(
436
+ p.get("total_output_tokens", 0) for p in project_summaries
437
+ )
438
+ self.total_cache_creation_tokens = sum(
439
+ p.get("total_cache_creation_tokens", 0) for p in project_summaries
440
+ )
441
+ self.total_cache_read_tokens = sum(
442
+ p.get("total_cache_read_tokens", 0) for p in project_summaries
443
+ )
444
+
445
+ # Find the most recent and earliest interaction timestamps across all projects
446
+ self.latest_interaction = ""
447
+ self.earliest_interaction = ""
448
+ for project in project_summaries:
449
+ # Check latest timestamp
450
+ latest_timestamp = project.get("latest_timestamp", "")
451
+ if latest_timestamp and (
452
+ not self.latest_interaction
453
+ or latest_timestamp > self.latest_interaction
454
+ ):
455
+ self.latest_interaction = latest_timestamp
456
+
457
+ # Check earliest timestamp
458
+ earliest_timestamp = project.get("earliest_timestamp", "")
459
+ if earliest_timestamp and (
460
+ not self.earliest_interaction
461
+ or earliest_timestamp < self.earliest_interaction
462
+ ):
463
+ self.earliest_interaction = earliest_timestamp
464
+
465
+ # Format the latest interaction timestamp
466
+ if self.latest_interaction:
467
+ self.formatted_latest_interaction = format_timestamp(
468
+ self.latest_interaction
469
+ )
470
+ else:
471
+ self.formatted_latest_interaction = ""
472
+
473
+ # Format the time range
474
+ if self.earliest_interaction and self.latest_interaction:
475
+ if self.earliest_interaction == self.latest_interaction:
476
+ # Single interaction
477
+ self.formatted_time_range = format_timestamp(self.latest_interaction)
478
+ else:
479
+ # Time range
480
+ earliest_formatted = format_timestamp(self.earliest_interaction)
481
+ latest_formatted = format_timestamp(self.latest_interaction)
482
+ self.formatted_time_range = (
483
+ f"{earliest_formatted} to {latest_formatted}"
484
+ )
485
+ else:
486
+ self.formatted_time_range = ""
487
+
488
+ # Format token usage summary
489
+ self.token_summary = ""
490
+ if self.total_input_tokens > 0 or self.total_output_tokens > 0:
491
+ token_parts: List[str] = []
492
+ if self.total_input_tokens > 0:
493
+ token_parts.append(f"Input: {self.total_input_tokens}")
494
+ if self.total_output_tokens > 0:
495
+ token_parts.append(f"Output: {self.total_output_tokens}")
496
+ if self.total_cache_creation_tokens > 0:
497
+ token_parts.append(
498
+ f"Cache Creation: {self.total_cache_creation_tokens}"
499
+ )
500
+ if self.total_cache_read_tokens > 0:
501
+ token_parts.append(f"Cache Read: {self.total_cache_read_tokens}")
502
+ self.token_summary = " | ".join(token_parts)
503
+
362
504
 
363
505
  def generate_html(messages: List[TranscriptEntry], title: Optional[str] = None) -> str:
364
506
  """Generate HTML from transcript messages using Jinja2 templates."""
@@ -604,9 +746,10 @@ def generate_html(messages: List[TranscriptEntry], title: Optional[str] = None)
604
746
  if command_args:
605
747
  content_parts.append(f"<strong>Args:</strong> {escaped_command_args}")
606
748
  if command_contents:
607
- content_parts.append(
608
- f"<details><summary>Content</summary><div class='content'>{escaped_command_contents}</div></details>"
749
+ details_html = create_collapsible_details(
750
+ "Content", escaped_command_contents
609
751
  )
752
+ content_parts.append(details_html)
610
753
 
611
754
  content_html = "<br>".join(content_parts)
612
755
  message_type = "system"
@@ -49,6 +49,7 @@
49
49
  font-size: 1.2em;
50
50
  font-weight: 600;
51
51
  margin-bottom: 10px;
52
+ word-break: break-all;
52
53
  }
53
54
 
54
55
  .project-name a {
@@ -89,9 +90,9 @@
89
90
 
90
91
  .summary-stats {
91
92
  display: flex;
92
- justify-content: center;
93
- gap: 30px;
94
- flex-wrap: wrap;
93
+ flex-direction: column;
94
+ align-items: center;
95
+ gap: 15px;
95
96
  }
96
97
 
97
98
  .summary-stat {
@@ -116,18 +117,32 @@
116
117
 
117
118
  <div class='summary'>
118
119
  <div class='summary-stats'>
119
- <div class='summary-stat'>
120
- <div class='number'>{{ summary.total_projects }}</div>
121
- <div class='label'>Projects</div>
120
+ <div style="display: flex; gap: 3em;">
121
+ <div class='summary-stat'>
122
+ <div class='number'>{{ summary.total_projects }}</div>
123
+ <div class='label'>Projects</div>
124
+ </div>
125
+ <div class='summary-stat'>
126
+ <div class='number'>{{ summary.total_jsonl }}</div>
127
+ <div class='label'>Transcript Files</div>
128
+ </div>
129
+ <div class='summary-stat'>
130
+ <div class='number'>{{ summary.total_messages }}</div>
131
+ <div class='label'>Messages</div>
132
+ </div>
122
133
  </div>
134
+ {% if summary.token_summary %}
123
135
  <div class='summary-stat'>
124
- <div class='number'>{{ summary.total_jsonl }}</div>
125
- <div class='label'>Transcript Files</div>
136
+ <div class='number'>🪙</div>
137
+ <div class='label'>{{ summary.token_summary }}</div>
126
138
  </div>
139
+ {% endif %}
140
+ {% if summary.formatted_time_range %}
127
141
  <div class='summary-stat'>
128
- <div class='number'>{{ summary.total_messages }}</div>
129
- <div class='label'>Messages</div>
142
+ <div class='number'>🕐</div>
143
+ <div class='label'>{{ summary.formatted_time_range }}</div>
130
144
  </div>
145
+ {% endif %}
131
146
  </div>
132
147
  </div>
133
148
 
@@ -140,7 +155,14 @@
140
155
  <div class='project-stats'>
141
156
  <div class='stat'>📁 {{ project.jsonl_count }} transcript files</div>
142
157
  <div class='stat'>💬 {{ project.message_count }} messages</div>
158
+ {% if project.formatted_time_range %}
159
+ <div class='stat'>🕒 {{ project.formatted_time_range }}</div>
160
+ {% else %}
143
161
  <div class='stat'>🕒 {{ project.formatted_date }}</div>
162
+ {% endif %}
163
+ {% if project.token_summary %}
164
+ <div class='stat'>🪙 {{ project.token_summary }}</div>
165
+ {% endif %}
144
166
  </div>
145
167
  </div>
146
168
  {% endfor %}