transcript-viewer 0.1.0

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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +154 -0
  4. data/assets/dark.json +85 -0
  5. data/assets/template.css +1078 -0
  6. data/lib/transcript_viewer/configuration.rb +17 -0
  7. data/lib/transcript_viewer/page.rb +42 -0
  8. data/lib/transcript_viewer/renderer.rb +367 -0
  9. data/lib/transcript_viewer/support.rb +38 -0
  10. data/lib/transcript_viewer/templates/app.html.erb +10 -0
  11. data/lib/transcript_viewer/templates/assistant_block.html.erb +10 -0
  12. data/lib/transcript_viewer/templates/compaction.html.erb +3 -0
  13. data/lib/transcript_viewer/templates/entry.html.erb +3 -0
  14. data/lib/transcript_viewer/templates/fetch_tool_table.html.erb +1 -0
  15. data/lib/transcript_viewer/templates/fork_bar.html.erb +1 -0
  16. data/lib/transcript_viewer/templates/hamburger.html.erb +3 -0
  17. data/lib/transcript_viewer/templates/header.html.erb +5 -0
  18. data/lib/transcript_viewer/templates/info_item.html.erb +1 -0
  19. data/lib/transcript_viewer/templates/main_content.html.erb +11 -0
  20. data/lib/transcript_viewer/templates/message_entry.html.erb +8 -0
  21. data/lib/transcript_viewer/templates/message_tree_content.html.erb +2 -0
  22. data/lib/transcript_viewer/templates/script.html.erb +80 -0
  23. data/lib/transcript_viewer/templates/server_tool_use.html.erb +4 -0
  24. data/lib/transcript_viewer/templates/session.html.erb +13 -0
  25. data/lib/transcript_viewer/templates/sidebar.html.erb +1 -0
  26. data/lib/transcript_viewer/templates/tool_call.html.erb +6 -0
  27. data/lib/transcript_viewer/templates/tree_content.html.erb +2 -0
  28. data/lib/transcript_viewer/templates/tree_node.html.erb +4 -0
  29. data/lib/transcript_viewer/tool_execution.rb +74 -0
  30. data/lib/transcript_viewer/tool_template_registry.rb +67 -0
  31. data/lib/transcript_viewer/version.rb +5 -0
  32. data/lib/transcript_viewer/view.rb +475 -0
  33. data/lib/transcript_viewer.rb +47 -0
  34. metadata +76 -0
@@ -0,0 +1,2 @@
1
+ <% case entry[:type]
2
+ when "toolPair" %><span class="tree-role-tool"><%= h(tool_pair_text(entry)) %></span><% when "message" %><%= render_message_tree_content(entry[:message]) %><% when "reasoningChange" %><span class="tree-muted">[reasoning: <%= h(presence(entry[:reasoning]) || "unknown") %>]</span><% when "modelChange" %><span class="tree-muted">[model: <%= h(presence(entry[:modelId]) || "unknown") %>]</span><% when "compaction" %><span class="tree-compaction">[compaction: <%= (entry[:tokensBefore].to_i / 1000.0).round %>k tokens]</span> <%= h(truncate_tree_text(entry[:summary])) %><% when "forkBoundary" %><span class="tree-fork-boundary">[fork: new session starts here]</span><% else %><span class="tree-muted">[<%= h(entry[:type]) %>]</span><% end %>
@@ -0,0 +1,4 @@
1
+ <%
2
+ classes = ["tree-node"]
3
+ classes << "active" if entry[:id] == @view[:leaf_id]
4
+ %><a class="<%= h(classes.join(" ")) %>" href="#<%= h(entry[:targetId] || entry[:id]) %>"><span class="tree-prefix"></span><span class="tree-marker">•</span><span class="tree-content"><%= render_tree_content(entry) %></span></a>
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TranscriptViewer
6
+ class ToolExecution
7
+ attr_reader :call, :result_entry
8
+
9
+ def initialize(call:, result_entry: nil)
10
+ @call = call
11
+ @result_entry = result_entry
12
+ end
13
+
14
+ def id
15
+ call[:id]
16
+ end
17
+
18
+ def name
19
+ call[:name].to_s
20
+ end
21
+
22
+ def input
23
+ call[:arguments] || {}
24
+ end
25
+ alias arguments input
26
+
27
+ def result
28
+ result_entry&.dig(:message)
29
+ end
30
+
31
+ def result_content
32
+ Array(result&.dig(:content))
33
+ end
34
+
35
+ def result_text
36
+ result_content.filter_map { |block| block[:text] if block[:type] == "text" }.join("\n")
37
+ end
38
+
39
+ def result_json
40
+ JSON.parse(result_text)
41
+ rescue JSON::ParserError
42
+ nil
43
+ end
44
+
45
+ def images
46
+ result_content.select { |block| block[:type] == "image" && present_value?(block[:data]) }
47
+ end
48
+
49
+ def pending?
50
+ result_entry.nil?
51
+ end
52
+
53
+ def error?
54
+ !pending? && !!result&.dig(:isError)
55
+ end
56
+
57
+ def success?
58
+ !pending? && !error?
59
+ end
60
+
61
+ def status
62
+ return :pending if pending?
63
+ return :error if error?
64
+
65
+ :success
66
+ end
67
+
68
+ private
69
+
70
+ def present_value?(value)
71
+ !value.nil? && (!value.respond_to?(:empty?) || !value.empty?)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TranscriptViewer
4
+ class ToolTemplateRegistry
5
+ include Enumerable
6
+
7
+ def initialize(templates = nil)
8
+ @templates = {}
9
+ merge!(templates) if templates
10
+ end
11
+
12
+ def register(tool_name, template_path)
13
+ name = normalize_name(tool_name)
14
+ path = File.expand_path(template_path.to_s)
15
+
16
+ raise ArgumentError, "Tool template does not exist: #{path}" unless File.file?(path)
17
+
18
+ @templates[name] = path
19
+ self
20
+ end
21
+
22
+ def unregister(tool_name)
23
+ @templates.delete(tool_name.to_s)
24
+ self
25
+ end
26
+
27
+ def resolve(tool_name)
28
+ @templates[tool_name.to_s]
29
+ end
30
+
31
+ def each(&block)
32
+ @templates.each(&block)
33
+ end
34
+
35
+ def merge(other)
36
+ self.class.new(self).merge!(other)
37
+ end
38
+
39
+ def merge!(other)
40
+ return self unless other
41
+
42
+ registrations = if other.is_a?(self.class)
43
+ other
44
+ elsif other.respond_to?(:each_pair)
45
+ other.each_pair
46
+ else
47
+ raise ArgumentError, "Tool templates must be a ToolTemplateRegistry or hash"
48
+ end
49
+
50
+ registrations.each { |tool_name, template_path| register(tool_name, template_path) }
51
+ self
52
+ end
53
+
54
+ def to_h
55
+ @templates.dup
56
+ end
57
+
58
+ private
59
+
60
+ def normalize_name(tool_name)
61
+ name = tool_name.to_s
62
+ raise ArgumentError, "Tool name cannot be empty" if name.empty?
63
+
64
+ name
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TranscriptViewer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+ require "time"
6
+
7
+ require_relative "support"
8
+
9
+ module TranscriptViewer
10
+ class View
11
+ DEFAULT_ASSET_DIR = File.expand_path("../../assets", __dir__)
12
+
13
+ def initialize(session_id:, events:, forked_transcript: false, ancestor_lookup: nil, export_href: nil, forked_transcript_href: nil, hide_forked_transcript_href: nil, asset_dir: nil)
14
+ @session_id = session_id
15
+ @events = events
16
+ @forked_transcript = !!forked_transcript
17
+ @ancestor_lookup = ancestor_lookup
18
+ @export_href = export_href
19
+ @forked_transcript_href = forked_transcript_href
20
+ @hide_forked_transcript_href = hide_forked_transcript_href
21
+ @asset_dir = asset_dir || DEFAULT_ASSET_DIR
22
+ end
23
+
24
+ def to_h
25
+ session_entries = load_session_entries
26
+ fork_parent_id = session_entries.first&.dig(:parent_id)
27
+ ancestor_entries = forked_transcript ? load_ancestor_entries(fork_parent_id) : []
28
+ raw_entries = ancestor_entries + session_entries
29
+ tool_name_by_call_id = build_tool_call_name_map(raw_entries)
30
+ ancestor_count = ancestor_entries.map { |entry| map_entry(entry, tool_name_by_call_id) }.compact.length
31
+ entries = normalize_parent_links(
32
+ raw_entries.map { |entry| map_entry(entry, tool_name_by_call_id) }.compact,
33
+ raw_entries,
34
+ )
35
+ leaf_id = entries.last&.dig(:id)
36
+
37
+ {
38
+ css: stylesheet,
39
+ header: build_header(entries),
40
+ entries: entries,
41
+ sidebar_entries: build_sidebar_entries(entries, ancestor_count: ancestor_count),
42
+ leaf_id: leaf_id,
43
+ path_entries: path_to_entry(entries, leaf_id),
44
+ stats: compute_stats(entries),
45
+ forked_transcript_available: Support.present?(fork_parent_id),
46
+ forked_transcript_enabled: forked_transcript,
47
+ forked_ancestor_count: ancestor_count,
48
+ export_href: export_href,
49
+ forked_transcript_href: forked_transcript_href || "?forked_transcript=1",
50
+ hide_forked_transcript_href: hide_forked_transcript_href || "?forked_transcript=0",
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :session_id, :events, :forked_transcript, :ancestor_lookup, :export_href, :forked_transcript_href, :hide_forked_transcript_href, :asset_dir
57
+
58
+ def load_session_entries
59
+ raw_entries = Array(events).map { |event| normalize_raw_event(event) }
60
+ raise EmptyTranscriptError, "Agent session has no events: #{session_id}" if raw_entries.empty?
61
+
62
+ raw_entries
63
+ end
64
+
65
+ def load_ancestor_entries(parent_id)
66
+ return [] unless ancestor_lookup
67
+
68
+ ancestors = []
69
+ seen_ids = Set.new
70
+ cursor_id = parent_id
71
+
72
+ while Support.present?(cursor_id) && !seen_ids.include?(cursor_id)
73
+ seen_ids << cursor_id
74
+ event = ancestor_lookup.call(cursor_id)
75
+ break unless event
76
+
77
+ normalized = normalize_raw_event(event)
78
+ ancestors.unshift(normalized)
79
+ cursor_id = normalized[:parent_id]
80
+ end
81
+
82
+ ancestors
83
+ end
84
+
85
+ def normalize_raw_event(event)
86
+ event = object_to_hash(event)
87
+ event = Support.symbolize(event)
88
+
89
+ {
90
+ id: event[:id] || event[:event_id],
91
+ parent_id: event[:parent_id] || event[:parentId],
92
+ timestamp: normalize_timestamp(event[:timestamp]),
93
+ type: event[:type] || event[:event_type],
94
+ usage: Support.symbolize(event[:usage] || event[:usage_json]),
95
+ data: Support.symbolize(event[:data] || event[:data_json] || {}),
96
+ }
97
+ end
98
+
99
+ def object_to_hash(event)
100
+ return event if event.is_a?(Hash)
101
+
102
+ {
103
+ id: value_from(event, :id) || value_from(event, :event_id),
104
+ parent_id: value_from(event, :parent_id),
105
+ timestamp: value_from(event, :timestamp),
106
+ type: value_from(event, :type) || value_from(event, :event_type),
107
+ usage: value_from(event, :usage) || value_from(event, :usage_json),
108
+ data: value_from(event, :data) || value_from(event, :data_json),
109
+ }
110
+ end
111
+
112
+ def value_from(object, name)
113
+ object.public_send(name) if object.respond_to?(name)
114
+ end
115
+
116
+ def normalize_timestamp(timestamp)
117
+ return timestamp.iso8601 if timestamp.respond_to?(:iso8601)
118
+
119
+ timestamp&.to_s
120
+ end
121
+
122
+ def stylesheet
123
+ read_asset_file("template.css")
124
+ .gsub("{{THEME_VARS}}", dark_theme_vars)
125
+ .gsub("{{BODY_BG}}", "#18181e")
126
+ .gsub("{{CONTAINER_BG}}", "#1e1e24")
127
+ .gsub("{{INFO_BG}}", "#3c3728")
128
+ end
129
+
130
+ def build_tool_call_name_map(raw_entries)
131
+ raw_entries.each_with_object({}) do |entry, map|
132
+ next unless entry[:type] == "message"
133
+
134
+ data = entry[:data]
135
+ next unless data[:role] == "assistant"
136
+ next unless data[:content].is_a?(Array)
137
+
138
+ data[:content].each do |block|
139
+ map[block[:id]] = block[:name] if block[:type] == "tool_use"
140
+ end
141
+ end
142
+ end
143
+
144
+ def normalize_parent_links(entries, raw_entries)
145
+ mapped_ids = entries.map { |entry| entry[:id] }.to_set
146
+ raw_parent_by_id = raw_entries.each_with_object({}) { |entry, hash| hash[entry[:id]] = entry[:parent_id] }
147
+
148
+ entries.map do |entry|
149
+ normalized = entry.dup
150
+ normalized[:parentId] = nearest_mapped_parent_id(entry[:parentId], mapped_ids, raw_parent_by_id)
151
+ normalized
152
+ end
153
+ end
154
+
155
+ def nearest_mapped_parent_id(parent_id, mapped_ids, raw_parent_by_id)
156
+ seen_ids = Set.new
157
+ cursor_id = parent_id
158
+
159
+ while Support.present?(cursor_id) && !seen_ids.include?(cursor_id)
160
+ return cursor_id if mapped_ids.include?(cursor_id)
161
+
162
+ seen_ids << cursor_id
163
+ cursor_id = raw_parent_by_id[cursor_id]
164
+ end
165
+
166
+ nil
167
+ end
168
+
169
+ def map_entry(entry, tool_name_by_call_id)
170
+ case entry[:type]
171
+ when "message"
172
+ map_message_entry(entry, tool_name_by_call_id)
173
+ when "reasoning_change"
174
+ {
175
+ id: entry[:id],
176
+ parentId: entry[:parent_id],
177
+ timestamp: entry[:timestamp],
178
+ type: "reasoningChange",
179
+ reasoning: entry.dig(:data, :reasoning).to_s,
180
+ }
181
+ when "model_change"
182
+ {
183
+ id: entry[:id],
184
+ parentId: entry[:parent_id],
185
+ timestamp: entry[:timestamp],
186
+ type: "modelChange",
187
+ modelId: entry.dig(:data, :model_id).to_s,
188
+ }
189
+ when "compaction"
190
+ {
191
+ id: entry[:id],
192
+ parentId: entry[:parent_id],
193
+ timestamp: entry[:timestamp],
194
+ type: "compaction",
195
+ summary: compaction_summary(entry),
196
+ tokensBefore: compaction_tokens_before(entry),
197
+ }
198
+ end
199
+ end
200
+
201
+ def map_message_entry(entry, tool_name_by_call_id)
202
+ data = entry[:data]
203
+
204
+ mapped = {
205
+ id: entry[:id],
206
+ parentId: entry[:parent_id],
207
+ timestamp: entry[:timestamp],
208
+ type: "message",
209
+ message: nil,
210
+ }
211
+
212
+ if data[:role] == "user" && tool_result_message?(data)
213
+ tool_result_block = data[:content].find { |block| block[:type] == "tool_result" }
214
+ mapped[:message] = {
215
+ role: "toolResult",
216
+ toolCallId: tool_result_block[:tool_use_id],
217
+ toolName: tool_name_by_call_id[tool_result_block[:tool_use_id]],
218
+ content: normalize_tool_result_content(tool_result_block[:content]),
219
+ isError: false,
220
+ details: nil,
221
+ }
222
+ return mapped
223
+ end
224
+
225
+ mapped[:message] = {
226
+ role: data[:role],
227
+ content: normalize_message_content(data[:content]),
228
+ stopReason: normalize_stop_reason(data[:stop_reason]),
229
+ errorMessage: data[:error_message],
230
+ provider: data[:provider],
231
+ model: data[:model],
232
+ usage: normalize_usage(entry[:usage] || data[:usage]),
233
+ }
234
+
235
+ mapped
236
+ end
237
+
238
+ def tool_result_message?(data)
239
+ data[:content].is_a?(Array) && data[:content].any? && data[:content].all? { |block| block[:type] == "tool_result" }
240
+ end
241
+
242
+ def normalize_stop_reason(stop_reason)
243
+ stop_reason == "tool_use" ? "toolUse" : stop_reason
244
+ end
245
+
246
+ def normalize_usage(usage)
247
+ return if Support.blank?(usage)
248
+
249
+ input_details = usage[:input_token_details] || usage[:input_tokens_details] || {}
250
+ output_details = usage[:output_token_details] || usage[:output_tokens_details] || {}
251
+
252
+ {
253
+ input: usage[:total_input_tokens] || usage[:input] || usage[:input_tokens] || input_details[:input_tokens] || 0,
254
+ output: usage[:total_output_tokens] || usage[:output] || usage[:output_tokens] || output_details[:output_tokens] || 0,
255
+ cacheRead: usage[:cache_read] || input_details[:cache_read_input_tokens] || input_details[:cached_tokens] || 0,
256
+ cacheWrite: usage[:cache_write] || input_details[:cache_creation_input_tokens] || 0,
257
+ }
258
+ end
259
+
260
+ def compaction_summary(entry)
261
+ direct_summary = entry.dig(:data, :summary)
262
+ return direct_summary.to_s if Support.present?(direct_summary)
263
+
264
+ content = entry.dig(:data, :content)
265
+ return "" unless content.is_a?(Array)
266
+
267
+ content.filter_map do |block|
268
+ block = Support.symbolize(block)
269
+ case block[:type]
270
+ when "text", "input_text", "output_text"
271
+ block[:text]
272
+ when "reasoning"
273
+ block[:reasoning]
274
+ when "thinking"
275
+ block[:thinking]
276
+ end
277
+ end.join("\n\n")
278
+ end
279
+
280
+ def compaction_tokens_before(entry)
281
+ usage = entry[:usage] || entry.dig(:data, :usage) || {}
282
+ entry.dig(:data, :tokens_before) || entry.dig(:data, :tokensBefore) ||
283
+ usage[:total_input_tokens] || usage[:input_tokens] || usage[:input] || 0
284
+ end
285
+
286
+ def normalize_message_content(content)
287
+ return [] unless content.is_a?(Array)
288
+
289
+ content.filter_map do |block|
290
+ case block[:type]
291
+ when "text", "input_text", "output_text"
292
+ { type: "text", text: block[:text].to_s }
293
+ when "thinking"
294
+ { type: "thinking", thinking: block[:thinking].to_s }
295
+ when "reasoning"
296
+ { type: "thinking", thinking: block[:reasoning].to_s }
297
+ when "tool_use"
298
+ {
299
+ type: "toolCall",
300
+ id: block[:id],
301
+ name: block[:name],
302
+ arguments: block[:input] || {},
303
+ }
304
+ when "server_tool_use"
305
+ {
306
+ type: "serverToolUse",
307
+ id: block[:id],
308
+ name: block[:name],
309
+ input: block[:input] || {},
310
+ }
311
+ when "server_tool_result"
312
+ {
313
+ type: "serverToolResult",
314
+ toolUseId: block[:tool_use_id],
315
+ content: block[:content] || {},
316
+ }
317
+ end
318
+ end
319
+ end
320
+
321
+ def normalize_tool_result_content(content)
322
+ if content.is_a?(Array)
323
+ content.map { |item| normalize_tool_result_item(item) }
324
+ else
325
+ [{ type: "text", text: content.to_s }]
326
+ end
327
+ end
328
+
329
+ def normalize_tool_result_item(item)
330
+ if item.is_a?(String)
331
+ { type: "text", text: item }
332
+ elsif item.is_a?(Hash)
333
+ item = Support.symbolize(item)
334
+ case item[:type]
335
+ when "text"
336
+ { type: "text", text: item[:text].to_s }
337
+ when "image"
338
+ { type: "image", data: item[:data], mimeType: item[:media_type] || item[:mime_type] || item[:mimeType] }
339
+ else
340
+ { type: "text", text: JSON.pretty_generate(item) }
341
+ end
342
+ else
343
+ { type: "text", text: item.to_s }
344
+ end
345
+ end
346
+
347
+ def build_header(entries)
348
+ {
349
+ id: session_id,
350
+ timestamp: entries.first&.dig(:timestamp),
351
+ }
352
+ end
353
+
354
+ def path_to_entry(entries, leaf_id)
355
+ by_id = entries.each_with_object({}) { |entry, hash| hash[entry[:id]] = entry }
356
+ path = []
357
+ cursor = by_id[leaf_id]
358
+ while cursor
359
+ path.unshift(cursor)
360
+ cursor = by_id[cursor[:parentId]]
361
+ end
362
+ return entries if path.length <= 1 && entries.length > 1 && entries.none? { |entry| Support.present?(entry[:parentId]) }
363
+
364
+ path.empty? ? entries : path
365
+ end
366
+
367
+ def build_sidebar_entries(entries, ancestor_count: 0)
368
+ sidebar_entries = entries.flat_map do |entry|
369
+ next tool_pair_sidebar_entries(entry, entries) if entry.dig(:message, :role) == "toolResult"
370
+
371
+ [entry, *tool_pair_sidebar_entries(entry, entries)]
372
+ end
373
+
374
+ insert_fork_boundary(sidebar_entries, entries, ancestor_count)
375
+ end
376
+
377
+ def insert_fork_boundary(sidebar_entries, entries, ancestor_count)
378
+ return sidebar_entries unless forked_transcript && ancestor_count.positive?
379
+
380
+ first_session_id = entries[ancestor_count]&.dig(:id)
381
+ boundary_parent_id = entries[ancestor_count - 1]&.dig(:id)
382
+ insert_index = sidebar_entries.index { |entry| entry[:id] == first_session_id || entry[:targetId] == first_session_id } || sidebar_entries.length
383
+ sidebar_entries.insert(insert_index, fork_boundary_entry(boundary_parent_id))
384
+ sidebar_entries
385
+ end
386
+
387
+ def fork_boundary_entry(parent_id)
388
+ {
389
+ id: "fork-boundary",
390
+ parentId: parent_id,
391
+ targetId: "fork-boundary",
392
+ type: "forkBoundary",
393
+ }
394
+ end
395
+
396
+ def tool_pair_sidebar_entries(entry, entries)
397
+ return [] unless entry[:type] == "message"
398
+
399
+ msg = entry[:message]
400
+ return [] unless msg[:role] == "assistant"
401
+
402
+ tool_result_by_call_id = entries
403
+ .select { |candidate| candidate.dig(:message, :role) == "toolResult" }
404
+ .each_with_object({}) { |candidate, hash| hash[candidate.dig(:message, :toolCallId)] = candidate }
405
+
406
+ msg[:content].filter_map.with_index do |block, index|
407
+ case block[:type]
408
+ when "toolCall"
409
+ result = tool_result_by_call_id[block[:id]]
410
+ build_tool_pair_entry(parent_entry: entry, tool_id: block[:id], tool_name: block[:name], input: block[:arguments], result_content: result&.dig(:message, :content), target_id: result&.dig(:id))
411
+ when "serverToolUse"
412
+ result_block = msg[:content][(index + 1)..]&.find { |candidate| candidate[:type] == "serverToolResult" && candidate[:toolUseId] == block[:id] }
413
+ build_tool_pair_entry(parent_entry: entry, tool_id: block[:id], tool_name: block[:name], input: block[:input], result_content: result_block&.dig(:content), target_id: entry[:id])
414
+ end
415
+ end
416
+ end
417
+
418
+ def build_tool_pair_entry(parent_entry:, tool_id:, tool_name:, input:, result_content:, target_id:)
419
+ {
420
+ id: "#{parent_entry[:id]}:tool:#{tool_id}",
421
+ parentId: parent_entry[:id],
422
+ targetId: target_id || parent_entry[:id],
423
+ timestamp: parent_entry[:timestamp],
424
+ type: "toolPair",
425
+ toolName: tool_name,
426
+ input: input || {},
427
+ resultContent: result_content,
428
+ }
429
+ end
430
+
431
+ def compute_stats(entries)
432
+ stats = { developer: 0, user: 0, assistant: 0, tool_results: 0, compactions: 0, tool_calls: 0, tokens: Hash.new(0), models: Set.new }
433
+ entries.each do |entry|
434
+ if entry[:type] == "compaction"
435
+ stats[:compactions] += 1
436
+ next
437
+ end
438
+ next unless entry[:type] == "message"
439
+
440
+ msg = entry[:message]
441
+ stats[:developer] += 1 if msg[:role] == "developer"
442
+ stats[:user] += 1 if msg[:role] == "user"
443
+ stats[:tool_results] += 1 if msg[:role] == "toolResult"
444
+ next unless msg[:role] == "assistant"
445
+
446
+ stats[:assistant] += 1
447
+ stats[:models] << [msg[:provider], msg[:model]].compact.join("/") if msg[:model]
448
+ stats[:tool_calls] += msg[:content].count { |block| block[:type] == "toolCall" }
449
+ [:input, :output, :cacheRead, :cacheWrite].each { |key| stats[:tokens][key] += msg[:usage][key].to_i } if msg[:usage]
450
+ end
451
+ stats
452
+ end
453
+
454
+ def dark_theme_vars
455
+ theme = JSON.parse(read_asset_file("dark.json"))
456
+ vars = theme["vars"] || {}
457
+ colors = theme["colors"] || {}
458
+
459
+ colors.map do |key, value|
460
+ resolved = if value.nil? || value == ""
461
+ "#e5e5e7"
462
+ elsif vars.key?(value)
463
+ vars[value]
464
+ else
465
+ value
466
+ end
467
+ "--#{key}: #{resolved};"
468
+ end.join("\n ")
469
+ end
470
+
471
+ def read_asset_file(relative_path)
472
+ File.read(File.join(asset_dir, relative_path))
473
+ end
474
+ end
475
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transcript_viewer/version"
4
+ require_relative "transcript_viewer/configuration"
5
+ require_relative "transcript_viewer/tool_execution"
6
+ require_relative "transcript_viewer/view"
7
+ require_relative "transcript_viewer/renderer"
8
+ require_relative "transcript_viewer/page"
9
+
10
+ module TranscriptViewer
11
+ class Error < StandardError; end
12
+ class EmptyTranscriptError < Error; end
13
+ class TemplateNotFoundError < Error; end
14
+
15
+ def self.configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def self.configure
20
+ yield(configuration)
21
+ end
22
+
23
+ def self.register_tool_template(tool_name, template_path)
24
+ configuration.register_tool_template(tool_name, template_path)
25
+ end
26
+
27
+ def self.page(session_id:, events:, forked_transcript: false, ancestor_lookup: nil, export_href: nil, forked_transcript_href: nil, hide_forked_transcript_href: nil, asset_dir: nil, template_dir: nil, tool_templates: nil, before_html: nil, after_html: nil)
28
+ Page.new(
29
+ session_id: session_id,
30
+ events: events,
31
+ forked_transcript: forked_transcript,
32
+ ancestor_lookup: ancestor_lookup,
33
+ export_href: export_href,
34
+ forked_transcript_href: forked_transcript_href,
35
+ hide_forked_transcript_href: hide_forked_transcript_href,
36
+ asset_dir: asset_dir,
37
+ template_dir: template_dir,
38
+ tool_templates: tool_templates,
39
+ before_html: before_html,
40
+ after_html: after_html,
41
+ )
42
+ end
43
+
44
+ def self.render(**options)
45
+ page(**options).render
46
+ end
47
+ end