diff --git a/lib/transcript_viewer/templates/header.html.erb b/lib/transcript_viewer/templates/header.html.erb
index e8f7d81..b109379 100644
--- a/lib/transcript_viewer/templates/header.html.erb
+++ b/lib/transcript_viewer/templates/header.html.erb
@@ -2,4 +2,4 @@
header = @view[:header]
stats = @view[:stats]
models = presence(stats[:models].to_a.join(", ")) || "unknown"
-%>
\ No newline at end of file
+%>
\ No newline at end of file
diff --git a/lib/transcript_viewer/view.rb b/lib/transcript_viewer/view.rb
index d0c8211..f886896 100644
--- a/lib/transcript_viewer/view.rb
+++ b/lib/transcript_viewer/view.rb
@@ -27,9 +27,9 @@ def to_h
ancestor_entries = forked_transcript ? load_ancestor_entries(fork_parent_id) : []
raw_entries = ancestor_entries + session_entries
tool_name_by_call_id = build_tool_call_name_map(raw_entries)
- ancestor_count = ancestor_entries.map { |entry| map_entry(entry, tool_name_by_call_id) }.compact.length
+ ancestor_count = ancestor_entries.sum { |entry| map_entries(entry, tool_name_by_call_id).length }
entries = normalize_parent_links(
- raw_entries.map { |entry| map_entry(entry, tool_name_by_call_id) }.compact,
+ raw_entries.flat_map { |entry| map_entries(entry, tool_name_by_call_id) },
raw_entries,
)
leaf_id = entries.last&.dig(:id)
@@ -166,39 +166,41 @@ def nearest_mapped_parent_id(parent_id, mapped_ids, raw_parent_by_id)
nil
end
- def map_entry(entry, tool_name_by_call_id)
+ def map_entries(entry, tool_name_by_call_id)
case entry[:type]
when "message"
- map_message_entry(entry, tool_name_by_call_id)
+ map_message_entries(entry, tool_name_by_call_id)
when "reasoning_change"
- {
+ [{
id: entry[:id],
parentId: entry[:parent_id],
timestamp: entry[:timestamp],
type: "reasoningChange",
reasoning: entry.dig(:data, :reasoning).to_s,
- }
+ }]
when "model_change"
- {
+ [{
id: entry[:id],
parentId: entry[:parent_id],
timestamp: entry[:timestamp],
type: "modelChange",
modelId: entry.dig(:data, :model_id).to_s,
- }
+ }]
when "compaction"
- {
+ [{
id: entry[:id],
parentId: entry[:parent_id],
timestamp: entry[:timestamp],
type: "compaction",
summary: compaction_summary(entry),
tokensBefore: compaction_tokens_before(entry),
- }
+ }]
+ else
+ []
end
end
- def map_message_entry(entry, tool_name_by_call_id)
+ def map_message_entries(entry, tool_name_by_call_id)
data = entry[:data]
mapped = {
@@ -210,16 +212,19 @@ def map_message_entry(entry, tool_name_by_call_id)
}
if data[:role] == "user" && tool_result_message?(data)
- tool_result_block = data[:content].find { |block| block[:type] == "tool_result" }
- mapped[:message] = {
- role: "toolResult",
- toolCallId: tool_result_block[:tool_use_id],
- toolName: tool_name_by_call_id[tool_result_block[:tool_use_id]],
- content: normalize_tool_result_content(tool_result_block[:content]),
- isError: false,
- details: nil,
- }
- return mapped
+ return data[:content].each_with_index.map do |tool_result_block, index|
+ mapped.merge(
+ id: tool_result_entry_id(entry[:id], index, data[:content].length),
+ message: {
+ role: "toolResult",
+ toolCallId: tool_result_block[:tool_use_id],
+ toolName: tool_name_by_call_id[tool_result_block[:tool_use_id]],
+ content: normalize_tool_result_content(tool_result_block[:content]),
+ isError: false,
+ details: nil,
+ },
+ )
+ end
end
mapped[:message] = {
@@ -232,7 +237,13 @@ def map_message_entry(entry, tool_name_by_call_id)
usage: normalize_usage(entry[:usage] || data[:usage]),
}
- mapped
+ [mapped]
+ end
+
+ def tool_result_entry_id(event_id, index, result_count)
+ return event_id if result_count == 1
+
+ "#{event_id}:tool-result:#{index}"
end
def tool_result_message?(data)
@@ -254,6 +265,7 @@ def normalize_usage(usage)
output: usage[:total_output_tokens] || usage[:output] || usage[:output_tokens] || output_details[:output_tokens] || 0,
cacheRead: usage[:cache_read] || input_details[:cache_read_input_tokens] || input_details[:cached_tokens] || 0,
cacheWrite: usage[:cache_write] || input_details[:cache_creation_input_tokens] || 0,
+ cost: (usage.dig(:cost, :total) || 0).to_f,
}
end
@@ -429,7 +441,7 @@ def build_tool_pair_entry(parent_entry:, tool_id:, tool_name:, input:, result_co
end
def compute_stats(entries)
- stats = { developer: 0, user: 0, assistant: 0, tool_results: 0, compactions: 0, tool_calls: 0, tokens: Hash.new(0), models: Set.new }
+ stats = { developer: 0, user: 0, assistant: 0, tool_results: 0, tool_calls: 0, cost: 0.0, compactions: 0, tokens: Hash.new(0), models: Set.new }
entries.each do |entry|
if entry[:type] == "compaction"
stats[:compactions] += 1
@@ -446,7 +458,10 @@ def compute_stats(entries)
stats[:assistant] += 1
stats[:models] << [msg[:provider], msg[:model]].compact.join("/") if msg[:model]
stats[:tool_calls] += msg[:content].count { |block| block[:type] == "toolCall" }
- [:input, :output, :cacheRead, :cacheWrite].each { |key| stats[:tokens][key] += msg[:usage][key].to_i } if msg[:usage]
+ if msg[:usage]
+ [:input, :output, :cacheRead, :cacheWrite].each { |key| stats[:tokens][key] += msg[:usage][key].to_i }
+ stats[:cost] += msg[:usage][:cost]
+ end
end
stats
end
diff --git a/test/test_transcript_viewer.rb b/test/test_transcript_viewer.rb
index be31530..125353e 100644
--- a/test/test_transcript_viewer.rb
+++ b/test/test_transcript_viewer.rb
@@ -19,7 +19,7 @@ def test_renders_basic_session
model: "model",
content: [{ type: "text", text: "hello " }],
},
- usage: { total_input_tokens: 1000, total_output_tokens: 10 },
+ usage: { total_input_tokens: 1000, total_output_tokens: 10, cost: { total: "0.0028135" } },
},
],
export_href: "/export.jsonl",
@@ -29,6 +29,7 @@ def test_renders_basic_session
assert_includes html, "hello <world>"
assert_includes html, "test/model"
assert_includes html, "/export.jsonl"
+ assert_includes html, "Cost:$0.003"
end
def test_page_renders_composable_parts
@@ -83,6 +84,42 @@ def test_renders_tool_call_and_result
assert_includes html, "hi"
end
+ def test_renders_every_result_from_a_multi_tool_result_message
+ html = TranscriptViewer.render(
+ session_id: "parallel-tools",
+ events: [
+ {
+ id: "assistant-1",
+ type: "message",
+ data: {
+ role: "assistant",
+ content: [
+ { type: "tool_use", id: "tool-1", name: "bash", input: { command: "echo first" } },
+ { type: "tool_use", id: "tool-2", name: "bash", input: { command: "echo second" } },
+ ],
+ },
+ },
+ {
+ id: "result-1",
+ parent_id: "assistant-1",
+ type: "message",
+ data: {
+ role: "user",
+ content: [
+ { type: "tool_result", tool_use_id: "tool-1", content: [{ type: "text", text: "first result" }] },
+ { type: "tool_result", tool_use_id: "tool-2", content: [{ type: "text", text: "second result" }] },
+ ],
+ },
+ },
+ ],
+ )
+
+ assert_includes html, "first result"
+ assert_includes html, "second result"
+ assert_equal 2, html.scan(/tool-execution success/).length
+ refute_includes html, "tool-execution pending"
+ end
+
def test_renders_linear_jsonl_without_parent_links
html = TranscriptViewer.render(
session_id: "linear",