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" -%>

Session: <%= h(header[:id] || "unknown") %>

T toggle thinking · O toggle tools
<% if presence(@view[:export_href]) %>↓ JSONL<% end %>
<%= info_item("Date:", format_time(header[:timestamp])) %><%= info_item("Models:", models) %><%= info_item("Messages:", message_parts(stats)) %><%= info_item("Tool Calls:", stats[:tool_calls]) %><%= info_item("Tokens:", token_parts(stats)) %><%= info_item("Cost:", "$0.000") %>
\ No newline at end of file +%>

Session: <%= h(header[:id] || "unknown") %>

T toggle thinking · O toggle tools
<% if presence(@view[:export_href]) %>↓ JSONL<% end %>
<%= info_item("Date:", format_time(header[:timestamp])) %><%= info_item("Models:", models) %><%= info_item("Messages:", message_parts(stats)) %><%= info_item("Tool Calls:", stats[:tool_calls]) %><%= info_item("Tokens:", token_parts(stats)) %><%= info_item("Cost:", format("$%.3f", stats[:cost])) %>
\ 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",