Skip to content

Commit 62ff001

Browse files
ShlomoSteptclaude
andcommitted
Implement Phase 2 remaining items: metadata subsection and tool icons
A.2 Metadata Subsection: - Add calculate_message_metadata() function for char count, token estimate, tool counts - Add collapsible metadata section with info icon to each message - CSS styles: .message-metadata, .metadata-item, .metadata-label, .metadata-value - 7 new tests for metadata functionality B.3 Tool Call Headers with Type: - Add TOOL_ICONS constant with 14 tool-specific icons - Read (📖), Write (📝), Edit (✏️), Bash ($), Glob (🔍), Grep (🔎), etc. - Add get_tool_icon() helper function - Update tool_use macro to accept and display tool icon - Default icon (⚙) for unmapped tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 379ef41 commit 62ff001

7 files changed

Lines changed: 454 additions & 36 deletions

src/claude_code_transcripts/__init__.py

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,45 @@ def get_template(name):
5252
300 # Characters - text blocks longer than this are shown in index
5353
)
5454

55+
# Tool type icons for display in tool headers
56+
TOOL_ICONS = {
57+
# File operations
58+
"Read": "📖",
59+
"Write": "📝",
60+
"Edit": "✏️",
61+
"NotebookEdit": "📓",
62+
# Search/find operations
63+
"Glob": "🔍",
64+
"Grep": "🔎",
65+
# Terminal operations
66+
"Bash": "$",
67+
# Web operations
68+
"WebFetch": "🌐",
69+
"WebSearch": "🔎",
70+
# Task management
71+
"TodoWrite": "☰",
72+
"Task": "📋",
73+
# Other tools
74+
"Skill": "⚡",
75+
"Agent": "🤖",
76+
}
77+
78+
# Default icon for tools not in the mapping
79+
DEFAULT_TOOL_ICON = "⚙"
80+
81+
82+
def get_tool_icon(tool_name):
83+
"""Get the appropriate icon for a tool name.
84+
85+
Args:
86+
tool_name: The name of the tool.
87+
88+
Returns:
89+
The icon string for the tool.
90+
"""
91+
return TOOL_ICONS.get(tool_name, DEFAULT_TOOL_ICON)
92+
93+
5594
# Regex to strip ANSI escape sequences from terminal output
5695
ANSI_ESCAPE_PATTERN = re.compile(
5796
r"""
@@ -141,6 +180,61 @@ def highlight_code(code, filename=None, language=None):
141180
return highlighted
142181

143182

183+
def calculate_message_metadata(message_data):
184+
"""Calculate metadata for a message.
185+
186+
Args:
187+
message_data: Parsed message JSON data.
188+
189+
Returns:
190+
Dict with char_count, token_estimate, and tool_counts.
191+
"""
192+
content = message_data.get("content", "")
193+
194+
# Calculate character count from all text content
195+
if isinstance(content, str):
196+
char_count = len(content)
197+
elif isinstance(content, list):
198+
char_count = 0
199+
for block in content:
200+
if isinstance(block, dict):
201+
block_type = block.get("type", "")
202+
if block_type == "text":
203+
char_count += len(block.get("text", ""))
204+
elif block_type == "thinking":
205+
char_count += len(block.get("thinking", ""))
206+
elif block_type == "tool_use":
207+
# Count the input JSON as text
208+
char_count += len(json.dumps(block.get("input", {})))
209+
elif block_type == "tool_result":
210+
result_content = block.get("content", "")
211+
if isinstance(result_content, str):
212+
char_count += len(result_content)
213+
elif isinstance(result_content, list):
214+
for item in result_content:
215+
if isinstance(item, dict) and item.get("type") == "text":
216+
char_count += len(item.get("text", ""))
217+
else:
218+
char_count = len(str(content))
219+
220+
# Token estimate (approximately 4 characters per token)
221+
token_estimate = char_count // 4
222+
223+
# Count tool calls
224+
tool_counts = {}
225+
if isinstance(content, list):
226+
for block in content:
227+
if isinstance(block, dict) and block.get("type") == "tool_use":
228+
tool_name = block.get("name", "Unknown")
229+
tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
230+
231+
return {
232+
"char_count": char_count,
233+
"token_estimate": token_estimate,
234+
"tool_counts": tool_counts,
235+
}
236+
237+
144238
def extract_text_from_content(content):
145239
"""Extract plain text from message content.
146240
@@ -872,8 +966,14 @@ def render_content_block(block):
872966
display_input = {k: v for k, v in tool_input.items() if k != "description"}
873967
input_markdown_html = render_json_with_markdown(display_input)
874968
input_json_html = format_json(display_input)
969+
tool_icon = get_tool_icon(tool_name)
875970
return _macros.tool_use(
876-
tool_name, description_html, input_markdown_html, input_json_html, tool_id
971+
tool_name,
972+
tool_icon,
973+
description_html,
974+
input_markdown_html,
975+
input_json_html,
976+
tool_id,
877977
)
878978
elif block_type == "tool_result":
879979
content = block.get("content", "")
@@ -1290,7 +1390,14 @@ def render_message(log_type, message_json, timestamp):
12901390
if not content_html.strip():
12911391
return ""
12921392
msg_id = make_msg_id(timestamp)
1293-
return _macros.message(role_class, role_label, msg_id, timestamp, content_html)
1393+
# Calculate and render metadata
1394+
metadata = calculate_message_metadata(message_data)
1395+
metadata_html = _macros.metadata(
1396+
metadata["char_count"], metadata["token_estimate"], metadata["tool_counts"]
1397+
)
1398+
return _macros.message(
1399+
role_class, role_label, msg_id, timestamp, content_html, metadata_html
1400+
)
12941401

12951402

12961403
def render_message_with_tool_pairs(
@@ -1318,7 +1425,14 @@ def render_message_with_tool_pairs(
13181425
if not content_html.strip():
13191426
return ""
13201427
msg_id = make_msg_id(timestamp)
1321-
return _macros.message(role_class, role_label, msg_id, timestamp, content_html)
1428+
# Calculate and render metadata
1429+
metadata = calculate_message_metadata(message_data)
1430+
metadata_html = _macros.metadata(
1431+
metadata["char_count"], metadata["token_estimate"], metadata["tool_counts"]
1432+
)
1433+
return _macros.message(
1434+
role_class, role_label, msg_id, timestamp, content_html, metadata_html
1435+
)
13221436

13231437

13241438
CSS = """
@@ -1460,7 +1574,7 @@ def render_message_with_tool_pairs(
14601574
.cell-copy-btn.copied { background: var(--accent-green-bg); color: var(--accent-green); border-color: var(--accent-green); }
14611575
.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; }
14621576
.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; }
1463-
.tool-icon { font-size: var(--font-size-lg); }
1577+
.tool-icon { font-size: var(--font-size-lg); min-width: 1.5em; text-align: center; }
14641578
.tool-description { font-size: var(--font-size-sm); color: var(--text-muted); margin-bottom: var(--spacing-sm); font-style: italic; }
14651579
.tool-description p { margin: 0; }
14661580
.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: var(--font-size-sm); line-height: 1.5; }
@@ -1629,6 +1743,16 @@ def render_message_with_tool_pairs(
16291743
.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); }
16301744
.search-result-content { padding: var(--spacing-md); }
16311745
.search-result mark { background: rgba(245, 158, 11, 0.3); padding: 1px 2px; border-radius: 2px; }
1746+
/* Metadata subsection */
1747+
.message-metadata { margin: 0; border-radius: var(--border-radius-sm); font-size: var(--font-size-xs); }
1748+
.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); }
1749+
.message-metadata summary::-webkit-details-marker { display: none; }
1750+
.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); }
1751+
.message-metadata[open] summary { border-bottom: 1px solid var(--border-light); }
1752+
.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); }
1753+
.metadata-item { display: flex; align-items: center; gap: var(--spacing-xs); }
1754+
.metadata-label { color: var(--text-muted); font-weight: 500; }
1755+
.metadata-value { color: var(--text-secondary); font-family: monospace; }
16321756
@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; } }
16331757
"""
16341758

src/claude_code_transcripts/templates/macros.html

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@
145145
{%- endmacro %}
146146

147147
{# Generic tool use - description_html, input_markdown_html, input_json_html are pre-rendered so need |safe #}
148-
{% macro tool_use(tool_name, description_html, input_markdown_html, input_json_html, tool_id) %}
149-
<div class="tool-use" data-tool-id="{{ tool_id }}"><div class="tool-header"><span class="tool-call-label"><span class="call-icon"></span> Call</span><span class="tool-icon"></span> {{ tool_name }}<div class="view-toggle" role="tablist"><button class="view-toggle-tab active" role="tab" aria-selected="true" data-view="markdown">Markdown</button><button class="view-toggle-tab" role="tab" aria-selected="false" data-view="json">JSON</button></div></div>
148+
{% macro tool_use(tool_name, tool_icon, description_html, input_markdown_html, input_json_html, tool_id) %}
149+
<div class="tool-use" data-tool-id="{{ tool_id }}"><div class="tool-header"><span class="tool-call-label"><span class="call-icon"></span> Call</span><span class="tool-icon">{{ tool_icon }}</span> {{ tool_name }}<div class="view-toggle" role="tablist"><button class="view-toggle-tab active" role="tab" aria-selected="true" data-view="markdown">Markdown</button><button class="view-toggle-tab" role="tab" aria-selected="false" data-view="json">JSON</button></div></div>
150150
{%- if description_html -%}
151151
<div class="tool-description">{{ description_html|safe }}</div>
152152
{%- endif -%}
@@ -211,9 +211,25 @@
211211
{%- endif %}
212212
{%- endmacro %}
213213

214-
{# Message wrapper - content_html is pre-rendered so needs |safe #}
215-
{% macro message(role_class, role_label, msg_id, timestamp, content_html) %}
216-
<div class="message {{ role_class }}" id="{{ msg_id }}"><div class="message-header"><span class="role-label">{{ role_label }}</span><a href="#{{ msg_id }}" class="timestamp-link"><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></a></div><div class="message-content">{{ content_html|safe }}</div></div>
214+
{# Message metadata subsection #}
215+
{% macro metadata(char_count, token_estimate, tool_counts) %}
216+
<details class="message-metadata">
217+
<summary>Metadata</summary>
218+
<div class="metadata-content">
219+
<div class="metadata-item"><span class="metadata-label">Chars:</span><span class="metadata-value">{{ char_count }}</span></div>
220+
<div class="metadata-item"><span class="metadata-label">Tokens:</span><span class="metadata-value">~{{ token_estimate }}</span></div>
221+
{%- if tool_counts %}
222+
{%- for tool_name, count in tool_counts.items() %}
223+
<div class="metadata-item"><span class="metadata-label">{{ tool_name }}:</span><span class="metadata-value">{{ count }}</span></div>
224+
{%- endfor %}
225+
{%- endif %}
226+
</div>
227+
</details>
228+
{%- endmacro %}
229+
230+
{# Message wrapper - content_html is pre-rendered so needs |safe, metadata_html is optional #}
231+
{% macro message(role_class, role_label, msg_id, timestamp, content_html, metadata_html="") %}
232+
<div class="message {{ role_class }}" id="{{ msg_id }}"><div class="message-header"><span class="role-label">{{ role_label }}</span><a href="#{{ msg_id }}" class="timestamp-link"><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></a></div>{{ metadata_html|safe }}<div class="message-content">{{ content_html|safe }}</div></div>
217233
{%- endmacro %}
218234

219235
{# Continuation wrapper - content_html is pre-rendered so needs |safe #}

tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
.cell-copy-btn.copied { background: var(--accent-green-bg); color: var(--accent-green); border-color: var(--accent-green); }
144144
.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; }
145145
.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; }
146-
.tool-icon { font-size: var(--font-size-lg); }
146+
.tool-icon { font-size: var(--font-size-lg); min-width: 1.5em; text-align: center; }
147147
.tool-description { font-size: var(--font-size-sm); color: var(--text-muted); margin-bottom: var(--spacing-sm); font-style: italic; }
148148
.tool-description p { margin: 0; }
149149
.tool-input-rendered { font-family: monospace; white-space: pre-wrap; font-size: var(--font-size-sm); line-height: 1.5; }
@@ -312,6 +312,16 @@
312312
.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); }
313313
.search-result-content { padding: var(--spacing-md); }
314314
.search-result mark { background: rgba(245, 158, 11, 0.3); padding: 1px 2px; border-radius: 2px; }
315+
/* Metadata subsection */
316+
.message-metadata { margin: 0; border-radius: var(--border-radius-sm); font-size: var(--font-size-xs); }
317+
.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); }
318+
.message-metadata summary::-webkit-details-marker { display: none; }
319+
.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); }
320+
.message-metadata[open] summary { border-bottom: 1px solid var(--border-light); }
321+
.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); }
322+
.metadata-item { display: flex; align-items: center; gap: var(--spacing-xs); }
323+
.metadata-label { color: var(--text-muted); font-weight: 500; }
324+
.metadata-value { color: var(--text-secondary); font-family: monospace; }
315325
@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; } }
316326
</style>
317327
</head>

0 commit comments

Comments
 (0)