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.
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/.claude/settings.local.json +2 -1
- claude_code_log-0.2.6/.github/workflows/docs.yml +43 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/.gitignore +1 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/CHANGELOG.md +20 -1
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/PKG-INFO +12 -4
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/README.md +11 -3
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/converter.py +59 -2
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/parser.py +1 -2
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/renderer.py +179 -36
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/templates/index.html +32 -10
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/templates/transcript.html +123 -8
- claude_code_log-0.2.6/justfile +147 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/pyproject.toml +4 -1
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/uv.lock +1 -1
- claude_code_log-0.2.4/justfile +0 -59
- claude_code_log-0.2.4/scripts/generate_style_guide.py +0 -517
- claude_code_log-0.2.4/scripts/style_guide_output/index.html +0 -103
- claude_code_log-0.2.4/scripts/style_guide_output/index_style_guide.html +0 -159
- claude_code_log-0.2.4/scripts/style_guide_output/transcript_style_guide.html +0 -430
- claude_code_log-0.2.4/test/README.md +0 -204
- claude_code_log-0.2.4/test/__init__.py +0 -0
- claude_code_log-0.2.4/test/test_command_handling.py +0 -66
- claude_code_log-0.2.4/test/test_data/edge_cases.jsonl +0 -19
- claude_code_log-0.2.4/test/test_data/representative_messages.jsonl +0 -12
- claude_code_log-0.2.4/test/test_data/session_b.jsonl +0 -3
- claude_code_log-0.2.4/test/test_data/todowrite_examples.jsonl +0 -12
- claude_code_log-0.2.4/test/test_date_filtering.py +0 -173
- claude_code_log-0.2.4/test/test_filtering.py +0 -150
- claude_code_log-0.2.4/test/test_markdown_rendering.py +0 -128
- claude_code_log-0.2.4/test/test_message_filtering.py +0 -179
- claude_code_log-0.2.4/test/test_message_types.py +0 -67
- claude_code_log-0.2.4/test/test_path_conversion.py +0 -38
- claude_code_log-0.2.4/test/test_template_data.py +0 -328
- claude_code_log-0.2.4/test/test_template_rendering.py +0 -329
- claude_code_log-0.2.4/test/test_template_utils.py +0 -245
- claude_code_log-0.2.4/test/test_todowrite_rendering.py +0 -335
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/.github/workflows/ci.yml +0 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/CLAUDE.md +0 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/LICENSE +0 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/__init__.py +0 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/cli.py +0 -0
- {claude_code_log-0.2.4 → claude_code_log-0.2.6}/claude_code_log/models.py +0 -0
- {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
|
|
@@ -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
|
|
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.
|
|
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]}
|
|
114
|
-
f"
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
231
|
-
<div class="
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
120
|
-
<div class='
|
|
121
|
-
|
|
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'
|
|
125
|
-
<div class='label'>
|
|
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'
|
|
129
|
-
<div class='label'>
|
|
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 %}
|