Skip to content

Commit 8981b63

Browse files
committed
Pair tool_use with tool_result
Group tool calls with matching results by tool_use_id Add tool-pair wrapper and page snapshot updates
1 parent 8351f24 commit 8981b63

File tree

5 files changed

+189
-34
lines changed

5 files changed

+189
-34
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,66 @@ def render_user_message_content(message_data):
757757
return f"<p>{html.escape(str(content))}</p>"
758758

759759

760+
def filter_tool_result_blocks(content, paired_tool_ids):
761+
if not isinstance(content, list):
762+
return content
763+
filtered = []
764+
for block in content:
765+
if (
766+
isinstance(block, dict)
767+
and block.get("type") == "tool_result"
768+
and block.get("tool_use_id") in paired_tool_ids
769+
):
770+
continue
771+
filtered.append(block)
772+
return filtered
773+
774+
775+
def is_tool_result_content(content):
776+
if not isinstance(content, list) or not content:
777+
return False
778+
return all(
779+
isinstance(block, dict) and block.get("type") == "tool_result"
780+
for block in content
781+
)
782+
783+
784+
def render_user_message_content_with_tool_pairs(message_data, paired_tool_ids):
785+
content = message_data.get("content", "")
786+
if isinstance(content, str):
787+
return render_user_message_content(message_data)
788+
if isinstance(content, list):
789+
filtered = filter_tool_result_blocks(content, paired_tool_ids)
790+
if not filtered:
791+
return ""
792+
return "".join(render_content_block(block) for block in filtered)
793+
return f"<p>{html.escape(str(content))}</p>"
794+
795+
796+
def render_assistant_message_with_tool_pairs(
797+
message_data, tool_result_lookup, paired_tool_ids
798+
):
799+
content = message_data.get("content", [])
800+
if not isinstance(content, list):
801+
return f"<p>{html.escape(str(content))}</p>"
802+
parts = []
803+
for block in content:
804+
if not isinstance(block, dict):
805+
parts.append(f"<p>{html.escape(str(block))}</p>")
806+
continue
807+
if block.get("type") == "tool_use":
808+
tool_id = block.get("id", "")
809+
tool_result = tool_result_lookup.get(tool_id)
810+
if tool_result:
811+
paired_tool_ids.add(tool_id)
812+
tool_use_html = render_content_block(block)
813+
tool_result_html = render_content_block(tool_result)
814+
parts.append(_macros.tool_pair(tool_use_html, tool_result_html))
815+
continue
816+
parts.append(render_content_block(block))
817+
return "".join(parts)
818+
819+
760820
def render_assistant_message(message_data):
761821
content = message_data.get("content", [])
762822
if not isinstance(content, list):
@@ -877,6 +937,34 @@ def render_message(log_type, message_json, timestamp):
877937
return _macros.message(role_class, role_label, msg_id, timestamp, content_html)
878938

879939

940+
def render_message_with_tool_pairs(
941+
log_type, message_data, timestamp, tool_result_lookup, paired_tool_ids
942+
):
943+
if log_type == "user":
944+
content = message_data.get("content", "")
945+
filtered = filter_tool_result_blocks(content, paired_tool_ids)
946+
content_html = render_user_message_content_with_tool_pairs(
947+
message_data, paired_tool_ids
948+
)
949+
if not content_html.strip():
950+
return ""
951+
if is_tool_result_content(filtered):
952+
role_class, role_label = "tool-reply", "Tool reply"
953+
else:
954+
role_class, role_label = "user", "User"
955+
elif log_type == "assistant":
956+
content_html = render_assistant_message_with_tool_pairs(
957+
message_data, tool_result_lookup, paired_tool_ids
958+
)
959+
role_class, role_label = "assistant", "Assistant"
960+
else:
961+
return ""
962+
if not content_html.strip():
963+
return ""
964+
msg_id = make_msg_id(timestamp)
965+
return _macros.message(role_class, role_label, msg_id, timestamp, content_html)
966+
967+
880968
CSS = """
881969
: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; }
882970
* { box-sizing: border-box; }
@@ -913,6 +1001,8 @@ def render_message(log_type, message_json, timestamp):
9131001
.tool-description { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; font-style: italic; }
9141002
.tool-result { background: var(--tool-result-bg); border-radius: 8px; padding: 12px; margin: 12px 0; }
9151003
.tool-result.tool-error { background: var(--tool-error-bg); }
1004+
.tool-pair { border: 1px solid var(--tool-border); border-radius: 8px; padding: 8px; margin: 12px 0; background: rgba(156, 39, 176, 0.06); }
1005+
.tool-pair .tool-use, .tool-pair .tool-result { margin: 8px 0; }
9161006
.file-tool { border-radius: 8px; padding: 12px; margin: 12px 0; }
9171007
.write-tool { background: linear-gradient(135deg, #e3f2fd 0%, #e8f5e9 100%); border: 1px solid #4caf50; }
9181008
.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):
12191309
messages_html = []
12201310
for conv in page_convs:
12211311
is_first = True
1312+
parsed_messages = []
12221313
for log_type, message_json, timestamp in conv["messages"]:
1223-
msg_html = render_message(log_type, message_json, timestamp)
1314+
try:
1315+
message_data = json.loads(message_json)
1316+
except json.JSONDecodeError:
1317+
continue
1318+
parsed_messages.append((log_type, message_data, timestamp))
1319+
tool_result_lookup = {}
1320+
for log_type, message_data, _ in parsed_messages:
1321+
content = message_data.get("content", [])
1322+
if not isinstance(content, list):
1323+
continue
1324+
for block in content:
1325+
if (
1326+
isinstance(block, dict)
1327+
and block.get("type") == "tool_result"
1328+
and block.get("tool_use_id")
1329+
):
1330+
tool_id = block.get("tool_use_id")
1331+
if tool_id not in tool_result_lookup:
1332+
tool_result_lookup[tool_id] = block
1333+
paired_tool_ids = set()
1334+
for log_type, message_data, timestamp in parsed_messages:
1335+
msg_html = render_message_with_tool_pairs(
1336+
log_type, message_data, timestamp, tool_result_lookup, paired_tool_ids
1337+
)
12241338
if msg_html:
12251339
# Wrap continuation summaries in collapsed details
12261340
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):
16891803
messages_html = []
16901804
for conv in page_convs:
16911805
is_first = True
1806+
parsed_messages = []
16921807
for log_type, message_json, timestamp in conv["messages"]:
1693-
msg_html = render_message(log_type, message_json, timestamp)
1808+
try:
1809+
message_data = json.loads(message_json)
1810+
except json.JSONDecodeError:
1811+
continue
1812+
parsed_messages.append((log_type, message_data, timestamp))
1813+
tool_result_lookup = {}
1814+
for log_type, message_data, _ in parsed_messages:
1815+
content = message_data.get("content", [])
1816+
if not isinstance(content, list):
1817+
continue
1818+
for block in content:
1819+
if (
1820+
isinstance(block, dict)
1821+
and block.get("type") == "tool_result"
1822+
and block.get("tool_use_id")
1823+
):
1824+
tool_id = block.get("tool_use_id")
1825+
if tool_id not in tool_result_lookup:
1826+
tool_result_lookup[tool_id] = block
1827+
paired_tool_ids = set()
1828+
for log_type, message_data, timestamp in parsed_messages:
1829+
msg_html = render_message_with_tool_pairs(
1830+
log_type, message_data, timestamp, tool_result_lookup, paired_tool_ids
1831+
)
16941832
if msg_html:
16951833
# Wrap continuation summaries in collapsed details
16961834
if is_first and conv.get("is_continuation"):

src/claude_code_transcripts/templates/macros.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@
116116
<div class="tool-result{{ error_class }}"><div class="truncatable"><div class="truncatable-content">{{ content_html|safe }}</div><button class="expand-btn">Show more</button></div></div>
117117
{%- endmacro %}
118118

119+
{# Tool pair wrapper - tool_use_html/tool_result_html are pre-rendered #}
120+
{% macro tool_pair(tool_use_html, tool_result_html) %}
121+
<div class="tool-pair">{{ tool_use_html|safe }}{{ tool_result_html|safe }}</div>
122+
{%- endmacro %}
123+
119124
{# Thinking block - content_html is pre-rendered markdown so needs |safe #}
120125
{% macro thinking(content_html) %}
121126
<div class="thinking"><div class="thinking-label">Thinking</div>{{ content_html|safe }}</div>

0 commit comments

Comments
 (0)