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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool_template_registry"
4
+
5
+ module TranscriptViewer
6
+ class Configuration
7
+ attr_reader :tool_templates
8
+
9
+ def initialize
10
+ @tool_templates = ToolTemplateRegistry.new
11
+ end
12
+
13
+ def register_tool_template(tool_name, template_path)
14
+ tool_templates.register(tool_name, template_path)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TranscriptViewer
4
+ class Page
5
+ 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, template_dir: nil, tool_templates: nil, before_html: nil, after_html: nil)
6
+ view = View.new(
7
+ session_id: session_id,
8
+ events: events,
9
+ forked_transcript: forked_transcript,
10
+ ancestor_lookup: ancestor_lookup,
11
+ export_href: export_href,
12
+ forked_transcript_href: forked_transcript_href,
13
+ hide_forked_transcript_href: hide_forked_transcript_href,
14
+ asset_dir: asset_dir,
15
+ ).to_h
16
+ view[:before_html] = before_html.to_s
17
+ view[:after_html] = after_html.to_s
18
+ registered_tool_templates = TranscriptViewer.configuration.tool_templates.merge(tool_templates)
19
+ @renderer = Renderer.new(
20
+ view,
21
+ template_dir: template_dir,
22
+ tool_templates: registered_tool_templates,
23
+ )
24
+ end
25
+
26
+ def render
27
+ @renderer.render
28
+ end
29
+
30
+ def stylesheet
31
+ @renderer.stylesheet
32
+ end
33
+
34
+ def app_html
35
+ @renderer.render_app
36
+ end
37
+
38
+ def javascript
39
+ @renderer.render_javascript
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+ require "json"
6
+ require "time"
7
+
8
+ require_relative "support"
9
+ require_relative "tool_execution"
10
+ require_relative "tool_template_registry"
11
+
12
+ module TranscriptViewer
13
+ class Renderer
14
+ def initialize(view, template_dir: nil, tool_templates: nil)
15
+ @view = view
16
+ @template_dir = template_dir
17
+ @tool_templates = ToolTemplateRegistry.new(tool_templates)
18
+ @tool_results_by_call_id = build_tool_result_index
19
+ end
20
+
21
+ def render
22
+ render_template("session")
23
+ end
24
+
25
+ def stylesheet
26
+ @view.fetch(:css)
27
+ end
28
+
29
+ def render_app
30
+ render_template("app")
31
+ end
32
+
33
+ def render_javascript
34
+ render_script
35
+ end
36
+
37
+ private
38
+
39
+ def render_template(name, locals = {})
40
+ render_template_file(template_path(name), locals)
41
+ end
42
+
43
+ def render_template_file(path, locals = {})
44
+ template_binding = binding
45
+ locals.each { |key, value| template_binding.local_variable_set(key, value) }
46
+ ERB.new(File.read(path), trim_mode: "-").result(template_binding)
47
+ end
48
+
49
+ def template_path(name)
50
+ relative_path = "#{name}.html.erb"
51
+ custom_path = File.expand_path(relative_path, @template_dir) if @template_dir
52
+ return custom_path if custom_path && File.file?(custom_path)
53
+
54
+ File.expand_path("templates/#{relative_path}", __dir__)
55
+ end
56
+
57
+ def render_hamburger
58
+ render_template("hamburger").strip
59
+ end
60
+
61
+ def render_sidebar
62
+ render_template("sidebar")
63
+ end
64
+
65
+ def render_tree_node(entry)
66
+ render_template("tree_node", entry: entry)
67
+ end
68
+
69
+ def render_tree_content(entry)
70
+ render_template("tree_content", entry: entry)
71
+ end
72
+
73
+ def render_message_tree_content(msg)
74
+ render_template("message_tree_content", msg: msg)
75
+ end
76
+
77
+ def render_main_content
78
+ render_template("main_content")
79
+ end
80
+
81
+ def render_header
82
+ render_template("header")
83
+ end
84
+
85
+ def info_item(label, value)
86
+ render_template("info_item", label: label, value: value)
87
+ end
88
+
89
+ def render_fork_bar
90
+ render_template("fork_bar")
91
+ end
92
+
93
+ def render_entry(entry)
94
+ render_template("entry", entry: entry)
95
+ end
96
+
97
+ def render_message_entry(entry)
98
+ render_template("message_entry", entry: entry)
99
+ end
100
+
101
+ def render_assistant_block(block, blocks, index)
102
+ render_template("assistant_block", block: block, blocks: blocks, index: index)
103
+ end
104
+
105
+ def render_server_tool_use(block, result_block)
106
+ render_template("server_tool_use", block: block, result_block: result_block)
107
+ end
108
+
109
+ def render_compaction(entry)
110
+ render_template("compaction", entry: entry)
111
+ end
112
+
113
+ def render_tool_call(call)
114
+ tool = ToolExecution.new(call: call, result_entry: @tool_results_by_call_id[call[:id]])
115
+ custom_template_path = @tool_templates.resolve(tool.name)
116
+ return render_tool_fallback(tool) unless custom_template_path
117
+
118
+ unless File.file?(custom_template_path)
119
+ raise TemplateNotFoundError, "Registered template for #{tool.name.inspect} does not exist: #{custom_template_path}"
120
+ end
121
+
122
+ render_template_file(custom_template_path, tool: tool)
123
+ end
124
+
125
+ def render_tool_fallback(tool)
126
+ render_template("tool_call", tool: tool, call: tool.call, entries: @view[:entries])
127
+ end
128
+
129
+ def render_fetch_tool_table(rows)
130
+ render_template("fetch_tool_table", rows: rows)
131
+ end
132
+
133
+ def timestamp_block(timestamp)
134
+ time = message_time(timestamp)
135
+ return "" unless present?(time)
136
+
137
+ %(<div class="message-timestamp">#{h(time)}</div>)
138
+ end
139
+
140
+ def timestamp_prefix(timestamp, inline: false)
141
+ time = message_time(timestamp)
142
+ return "" unless present?(time)
143
+
144
+ %(<span class="message-timestamp">#{h(time)}</span> · )
145
+ end
146
+
147
+ def render_markdownish(text)
148
+ normalized = text.to_s.gsub(/\r\n?/, "\n")
149
+ return "" if normalized.empty?
150
+
151
+ normalized.split(/\n{2,}/).map do |paragraph|
152
+ %(<p>#{h(paragraph).gsub("\n", "<br>")}</p>)
153
+ end.join
154
+ end
155
+
156
+ def format_tokens(value)
157
+ value = value.to_i
158
+ return "#{(value / 1000.0).round}k" if value >= 10_000
159
+ return format("%.1fk", value / 1000.0).sub(".0k", "k") if value >= 1_000
160
+
161
+ value.to_s
162
+ end
163
+
164
+ def format_time(timestamp)
165
+ return "unknown" if blank?(timestamp)
166
+
167
+ time = Time.parse(timestamp).getlocal
168
+ hour = time.strftime("%I").sub(/^0/, "")
169
+ "#{time.month}/#{time.day}/#{time.year}, #{hour}:#{time.strftime("%M:%S %p")}"
170
+ rescue ArgumentError, TypeError
171
+ timestamp.to_s
172
+ end
173
+
174
+ def message_time(timestamp)
175
+ return nil if blank?(timestamp)
176
+
177
+ format_time(timestamp).sub(%r{^\d+/\d+/\d+, }, "")
178
+ end
179
+
180
+ def message_parts(stats)
181
+ [].tap do |parts|
182
+ parts << "#{stats[:developer]} developer" if stats[:developer].positive?
183
+ parts << "#{stats[:user]} user" if stats[:user].positive?
184
+ parts << "#{stats[:assistant]} assistant" if stats[:assistant].positive?
185
+ parts << "#{stats[:tool_results]} tool results" if stats[:tool_results].positive?
186
+ parts << "#{stats[:compactions]} compactions" if stats[:compactions].positive?
187
+ end.join(", ")
188
+ end
189
+
190
+ def token_parts(stats)
191
+ tokens = stats[:tokens]
192
+ [
193
+ "↑#{format_tokens(tokens[:input])} (R#{format_tokens(tokens[:cacheRead])} W#{format_tokens(tokens[:cacheWrite])})",
194
+ "↓#{format_tokens(tokens[:output])}",
195
+ ].join(" ")
196
+ end
197
+
198
+ def tool_pair_text(entry)
199
+ input_text = truncate_tree_text(tool_input_text(entry), 90)
200
+ result_text = truncate_tree_text(extract_content(entry[:resultContent]), 80)
201
+ ["[#{entry[:toolName]}: #{input_text}]", presence(result_text)].compact.join(" → ")
202
+ end
203
+
204
+ def tool_input_text(entry)
205
+ input = entry[:input] || {}
206
+ command = input[:command] || input["command"]
207
+ path = input[:path] || input["path"]
208
+ return command if present?(command)
209
+ return path if present?(path)
210
+
211
+ display_json(input)
212
+ end
213
+
214
+ def display_json(value)
215
+ JSON.generate(value)
216
+ rescue JSON::GeneratorError, TypeError
217
+ value.to_s
218
+ end
219
+
220
+ def extract_content(content)
221
+ return display_json(content) if content.is_a?(Hash)
222
+ return content.to_s unless content.is_a?(Array)
223
+
224
+ content.filter_map do |block|
225
+ case block[:type]
226
+ when "text"
227
+ block[:text]
228
+ when "thinking"
229
+ block[:thinking]
230
+ when "toolCall"
231
+ "$ #{block[:name]}"
232
+ when "serverToolUse"
233
+ input = block[:input]
234
+ label = input.is_a?(Hash) ? input[:command] || input["command"] || input[:path] || input["path"] : ""
235
+ "$ #{block[:name]} #{label}".strip
236
+ when "serverToolResult"
237
+ "[server tool result]"
238
+ when "image"
239
+ "[image]"
240
+ end
241
+ end.join(" ")
242
+ end
243
+
244
+ def truncate_tree_text(text, max = 100)
245
+ normalized = text.to_s.gsub(/[\n\t]/, " ").strip
246
+ normalized.length > max ? "#{normalized[0, max]}..." : normalized
247
+ end
248
+
249
+ def build_tool_result_index
250
+ @view[:entries].each_with_object({}) do |entry, index|
251
+ next unless entry.dig(:message, :role) == "toolResult"
252
+
253
+ index[entry.dig(:message, :toolCallId)] = entry
254
+ end
255
+ end
256
+
257
+ def fetch_tool_call?(call)
258
+ call[:name].to_s.start_with?("fetch_")
259
+ end
260
+
261
+ def pretty_tool_arguments(call)
262
+ JSON.pretty_generate(normalized_tool_arguments(call[:arguments] || {}))
263
+ end
264
+
265
+ def tool_result_text(call, result)
266
+ output = tool_result_raw_text(result)
267
+ return output unless fetch_tool_call?(call)
268
+
269
+ JSON.pretty_generate(JSON.parse(output))
270
+ rescue JSON::ParserError
271
+ output
272
+ end
273
+
274
+ def tool_result_raw_text(result)
275
+ Array(result&.dig(:message, :content)).filter_map { |block| block[:text] if block[:type] == "text" }.join("\n")
276
+ end
277
+
278
+ def tool_result_images(result)
279
+ Array(result&.dig(:message, :content)).select { |block| block[:type] == "image" && present?(block[:data]) }
280
+ end
281
+
282
+ def fetch_tool_result_rows(result)
283
+ parsed = JSON.parse(tool_result_raw_text(result))
284
+ return parsed if parsed.is_a?(Array) && parsed.all? { |row| row.is_a?(Hash) }
285
+
286
+ []
287
+ rescue JSON::ParserError
288
+ []
289
+ end
290
+
291
+ def fetch_tool_result_headers(rows)
292
+ rows.flat_map(&:keys).uniq
293
+ end
294
+
295
+ def fetch_tool_table_value(value)
296
+ return "" if value.nil?
297
+ return value.to_s unless value.is_a?(Array) || value.is_a?(Hash)
298
+
299
+ JSON.generate(value)
300
+ end
301
+
302
+ def normalized_tool_arguments(arguments)
303
+ arguments = Support.symbolize(arguments)
304
+ parameters = arguments[:parameters] || arguments["parameters"]
305
+ return arguments unless parameters.is_a?(String)
306
+
307
+ arguments[:parameters] = JSON.parse(parameters)
308
+ arguments
309
+ rescue JSON::ParserError
310
+ arguments
311
+ end
312
+
313
+ def server_tool_label(block)
314
+ input = block[:input] || {}
315
+ command = input[:command] || input["command"]
316
+ path = input[:path] || input["path"]
317
+ return command if block[:name] == "bash_code_execution" && present?(command)
318
+
319
+ [block[:name], command || path].compact.join(" ")
320
+ end
321
+
322
+ def image_src(image)
323
+ "data:#{image[:mimeType] || image[:media_type] || "image/png"};base64,#{image[:data]}"
324
+ end
325
+
326
+ def server_tool_result_text(content)
327
+ return pretty_json(content) unless content.is_a?(Hash)
328
+
329
+ result = content[:content] || content["content"] || []
330
+ stdout = content[:stdout] || content["stdout"]
331
+ stderr = content[:stderr] || content["stderr"]
332
+ return [presence(stdout), presence(stderr)].compact.join("\n") if present?(stdout) || present?(stderr)
333
+
334
+ if result.is_a?(Array) && result.any?
335
+ return result.map { |item| item.is_a?(Hash) ? pretty_json(item) : item.to_s }.join("\n")
336
+ end
337
+
338
+ pretty_json(content)
339
+ end
340
+
341
+ def pretty_json(value)
342
+ JSON.pretty_generate(value)
343
+ rescue JSON::GeneratorError, TypeError
344
+ value.to_s
345
+ end
346
+
347
+ def h(value)
348
+ CGI.escapeHTML(value.to_s)
349
+ end
350
+
351
+ def blank?(value)
352
+ Support.blank?(value)
353
+ end
354
+
355
+ def present?(value)
356
+ Support.present?(value)
357
+ end
358
+
359
+ def presence(value)
360
+ Support.presence(value)
361
+ end
362
+
363
+ def render_script
364
+ render_template("script")
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TranscriptViewer
4
+ module Support
5
+ module_function
6
+
7
+ def blank?(value)
8
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
9
+ end
10
+
11
+ def present?(value)
12
+ !blank?(value)
13
+ end
14
+
15
+ def presence(value)
16
+ present?(value) ? value : nil
17
+ end
18
+
19
+ def symbolize(value)
20
+ case value
21
+ when Array
22
+ value.map { |item| symbolize(item) }
23
+ when Hash
24
+ value.each_with_object({}) do |(key, item), hash|
25
+ hash[symbolize_key(key)] = symbolize(item)
26
+ end
27
+ else
28
+ value
29
+ end
30
+ end
31
+
32
+ def symbolize_key(key)
33
+ key.respond_to?(:to_sym) ? key.to_sym : key
34
+ rescue StandardError
35
+ key
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,10 @@
1
+ <%= @view[:before_html] %>
2
+ <%= render_hamburger %>
3
+ <div id="sidebar-overlay"></div>
4
+ <div id="app">
5
+ <%= render_sidebar %>
6
+ <div id="sidebar-resizer" role="separator"></div>
7
+ <%= render_main_content %>
8
+ <div id="image-modal" class="image-modal"><img id="modal-image" src="" alt=""></div>
9
+ </div>
10
+ <%= @view[:after_html] %>
@@ -0,0 +1,10 @@
1
+ <% case block[:type]
2
+ when "text"
3
+ if present?(block[:text].to_s.strip)
4
+ %><div class="assistant-text markdown-content"><%= render_markdownish(block[:text]) %></div><% end %><% when "thinking"
5
+ if present?(block[:thinking].to_s.strip)
6
+ %><div class="thinking-block"><div class="thinking-text"><%= h(block[:thinking]) %></div><div class="thinking-collapsed">Thinking ...</div></div><% end %><% when "toolCall" %><%= render_tool_call(block) %><% when "serverToolUse" %><%
7
+ result_block = Array(blocks[(index + 1)..]).find { |candidate| candidate[:type] == "serverToolResult" && candidate[:toolUseId] == block[:id] }
8
+ %><%= render_server_tool_use(block, result_block) %><% when "serverToolResult" %><%
9
+ previous_use = Array(blocks[0...index]).find { |candidate| candidate[:type] == "serverToolUse" && candidate[:id] == block[:toolUseId] }
10
+ %><% unless previous_use %><div class="tool-execution success"><div class="tool-command">[server tool result]</div><div class="tool-output"><pre><%= h(server_tool_result_text(block[:content])) %></pre></div></div><% end %><% end %>
@@ -0,0 +1,3 @@
1
+ <div class="compaction" id="<%= h(entry[:id]) %>"><%= timestamp_block(entry[:timestamp]) %><div class="compaction-label">[compaction]</div><div class="compaction-collapsed">Compacted from <%= h(entry[:tokensBefore].to_i) %> tokens</div><div class="compaction-content"><strong>Compacted from <%= h(entry[:tokensBefore].to_i) %> tokens</strong>
2
+
3
+ <%= h(entry[:summary]) %></div></div>
@@ -0,0 +1,3 @@
1
+ <% case entry[:type]
2
+ when "compaction"
3
+ %><%= render_compaction(entry) %><% when "reasoningChange" %><div class="model-change" id="<%= h(entry[:id]) %>"><%= timestamp_prefix(entry[:timestamp], inline: true) %>Reasoning: <span class="model-name"><%= h(presence(entry[:reasoning]) || "unknown") %></span></div><% when "modelChange" %><div class="model-change" id="<%= h(entry[:id]) %>"><%= timestamp_prefix(entry[:timestamp], inline: true) %>Model: <span class="model-name"><%= h(presence(entry[:modelId]) || "unknown") %></span></div><% when "message" %><%= render_message_entry(entry) %><% end %>
@@ -0,0 +1 @@
1
+ <% headers = fetch_tool_result_headers(rows) %><div class="tool-output fetch-tool-result"><table><thead><tr><% headers.each do |header| %><th><%= h(header) %></th><% end %></tr></thead><tbody><% rows.each do |row| %><tr><% headers.each do |header| %><td><%= h(fetch_tool_table_value(row[header])) %></td><% end %></tr><% end %></tbody></table></div>
@@ -0,0 +1 @@
1
+ <% if @view[:forked_transcript_available] %><% if @view[:forked_transcript_enabled] %><div class="forked-transcript-bar" id="fork-boundary"><span>Showing previous messages from the fork point.</span><a class="header-toggle-btn" href="<%= h(@view[:hide_forked_transcript_href]) %>">Hide previous messages</a></div><% else %><div class="forked-transcript-bar" id="fork-boundary"><span>This transcript starts from a message with earlier parent messages.</span><a class="header-toggle-btn" href="<%= h(@view[:forked_transcript_href]) %>">Load previous messages here</a></div><% end %><% end %>
@@ -0,0 +1,3 @@
1
+ <button id="hamburger" title="Open sidebar">
2
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><rect x="5" y="6" width="2" height="12"/><path d="M6 12h10c1 0 2 0 2-2V8"/></svg>
3
+ </button>
@@ -0,0 +1,5 @@
1
+ <%
2
+ header = @view[:header]
3
+ stats = @view[:stats]
4
+ models = presence(stats[:models].to_a.join(", ")) || "unknown"
5
+ %><div class="header"><h1>Session: <%= h(header[:id] || "unknown") %></h1><div class="help-bar"><span class="help-hint">T toggle thinking · O toggle tools</span><div class="help-actions"><button type="button" class="header-toggle-btn" data-toggle="thinking" aria-pressed="false">Toggle thinking</button><button type="button" class="header-toggle-btn" data-toggle="tools" aria-pressed="false">Toggle tools</button><% if presence(@view[:export_href]) %><a class="download-json-btn" href="<%= h(@view[:export_href]) %>">↓ JSONL</a><% end %></div></div><div class="header-info"><%= 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") %></div></div>
@@ -0,0 +1 @@
1
+ <div class="info-item"><span class="info-label"><%= h(label) %></span><span class="info-value"><%= h(value) %></span></div>
@@ -0,0 +1,11 @@
1
+ <%
2
+ path_entries = @view[:path_entries]
3
+ messages_html = if @view[:forked_transcript_enabled]
4
+ ancestor_count = @view[:forked_ancestor_count].to_i
5
+ path_entries.first(ancestor_count).map { |entry| render_entry(entry) }.join +
6
+ render_fork_bar +
7
+ path_entries.drop(ancestor_count).map { |entry| render_entry(entry) }.join
8
+ else
9
+ path_entries.map { |entry| render_entry(entry) }.join
10
+ end
11
+ %><main id="content"><div id="header-container"><%= render_header %></div><% unless @view[:forked_transcript_enabled] %><%= render_fork_bar %><% end %><div id="messages"><%= messages_html %></div></main>
@@ -0,0 +1,8 @@
1
+ <%
2
+ msg = entry[:message]
3
+ %><% case msg[:role]
4
+ when "user", "developer"
5
+ classes = ["user-message"]
6
+ classes << "developer-message" if msg[:role] == "developer"
7
+ text = Array(msg[:content]).filter_map { |block| block[:text] if block[:type] == "text" }.join("\n")
8
+ %><div class="<%= h(classes.join(" ")) %>" id="<%= h(entry[:id]) %>"><%= timestamp_block(entry[:timestamp]) %><% if msg[:role] == "developer" %><div class="compaction-label">[developer]</div><% end %><div class="markdown-content"><%= render_markdownish(text) %></div></div><% when "assistant" %><% blocks = Array(msg[:content]) %><div class="assistant-message" id="<%= h(entry[:id]) %>"><%= timestamp_block(entry[:timestamp]) %><%= blocks.each_with_index.map { |block, index| render_assistant_block(block, blocks, index) }.join %></div><% end %>
@@ -0,0 +1,2 @@
1
+ <% case msg[:role]
2
+ when "developer" %><span class="tree-muted">developer:</span> <%= h(truncate_tree_text(extract_content(msg[:content]))) %><% when "user" %><span class="tree-role-user">user:</span> <%= h(truncate_tree_text(extract_content(msg[:content]))) %><% when "assistant" %><% text = presence(truncate_tree_text(extract_content(msg[:content]))) || "(no text)" %><span class="tree-role-assistant">assistant:</span> <%= h(text) %><% when "toolResult" %><span class="tree-role-tool">[<%= h(msg[:toolName] || "tool") %>]</span> <%= h(truncate_tree_text(extract_content(msg[:content]))) %><% when "bashExecution" %><span class="tree-role-tool">[bash]:</span> <%= h(truncate_tree_text(msg[:command].to_s)) %><% else %><span class="tree-muted">[<%= h(msg[:role]) %>]</span><% end %>
@@ -0,0 +1,80 @@
1
+ <script>
2
+ (function(){
3
+ function setToggle(name, enabled){
4
+ document.body.classList.toggle('hide-' + name, enabled);
5
+ document.querySelectorAll('[data-toggle="' + name + '"]').forEach(function(button){
6
+ button.classList.toggle('active', enabled);
7
+ button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
8
+ });
9
+ try { localStorage.setItem('agent-session-hide-' + name, enabled ? '1' : '0'); } catch(e) {}
10
+ }
11
+
12
+ function toggle(name){
13
+ setToggle(name, !document.body.classList.contains('hide-' + name));
14
+ }
15
+
16
+ ['thinking', 'tools'].forEach(function(name){
17
+ try { setToggle(name, localStorage.getItem('agent-session-hide-' + name) === '1'); } catch(e) {}
18
+ });
19
+
20
+ var sidebar = document.getElementById('sidebar');
21
+ var overlay = document.getElementById('sidebar-overlay');
22
+ var hamburger = document.getElementById('hamburger');
23
+ var close = document.getElementById('sidebar-close');
24
+ function setSidebar(open){
25
+ if(!sidebar || !overlay) return;
26
+ sidebar.classList.toggle('open', open);
27
+ overlay.classList.toggle('open', open);
28
+ }
29
+ if(hamburger) hamburger.addEventListener('click', function(){ setSidebar(true); });
30
+ if(close) close.addEventListener('click', function(){ setSidebar(false); });
31
+ if(overlay) overlay.addEventListener('click', function(){ setSidebar(false); });
32
+
33
+ var search = document.getElementById('tree-search');
34
+ var status = document.getElementById('tree-status');
35
+ function updateSearch(){
36
+ if(!search) return;
37
+ var q = search.value.toLowerCase();
38
+ var shown = 0;
39
+ var total = 0;
40
+ document.querySelectorAll('.tree-node').forEach(function(node){
41
+ total++;
42
+ var match = !q || node.textContent.toLowerCase().indexOf(q) !== -1;
43
+ node.style.display = match ? '' : 'none';
44
+ if(match) shown++;
45
+ });
46
+ if(status) status.textContent = shown + ' / ' + total + ' entries';
47
+ }
48
+ if(search) search.addEventListener('input', updateSearch);
49
+
50
+ var resizer = document.getElementById('sidebar-resizer');
51
+ if(resizer && sidebar){
52
+ var resizing = false;
53
+ resizer.addEventListener('mousedown', function(e){ resizing = true; document.body.classList.add('sidebar-resizing'); e.preventDefault(); });
54
+ document.addEventListener('mousemove', function(e){
55
+ if(!resizing) return;
56
+ var width = Math.max(240, Math.min(840, e.clientX));
57
+ document.documentElement.style.setProperty('--sidebar-width', width + 'px');
58
+ });
59
+ document.addEventListener('mouseup', function(){ resizing = false; document.body.classList.remove('sidebar-resizing'); });
60
+ }
61
+
62
+ document.addEventListener('click',function(e){
63
+ var toggleButton = e.target.closest('[data-toggle]');
64
+ if(toggleButton){
65
+ toggle(toggleButton.getAttribute('data-toggle'));
66
+ return;
67
+ }
68
+
69
+ var x=e.target.closest('.expandable,.thinking-block,.compaction');
70
+ if(x&&!getSelection().toString())x.classList.toggle('expanded');
71
+ });
72
+
73
+ document.addEventListener('keydown', function(e){
74
+ if(e.metaKey || e.ctrlKey || e.altKey) return;
75
+ if(e.target && ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
76
+ if(e.key === 't' || e.key === 'T') toggle('thinking');
77
+ if(e.key === 'o' || e.key === 'O') toggle('tools');
78
+ });
79
+ })();
80
+ </script>
@@ -0,0 +1,4 @@
1
+ <%
2
+ input = block[:input] || {}
3
+ file_text = input[:file_text] || input["file_text"]
4
+ %><div class="tool-execution <%= result_block ? "success" : "pending" %>"><div class="tool-command">$ <%= h(server_tool_label(block)) %></div><% if present?(file_text) %><div class="tool-output"><pre><%= h(file_text) %></pre></div><% elsif present?(input) && block[:name] != "bash_code_execution" %><div class="tool-output"><pre><%= h(pretty_json(input)) %></pre></div><% end %><% if result_block %><div class="tool-output"><pre><%= h(server_tool_result_text(result_block[:content])) %></pre></div><% end %></div>
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Session Export</title>
7
+ <style><%= @view[:css] %></style>
8
+ </head>
9
+ <body>
10
+ <%= render_app %>
11
+ <%= render_javascript %>
12
+ </body>
13
+ </html>
@@ -0,0 +1 @@
1
+ <% sidebar_entries = @view[:sidebar_entries] %><aside id="sidebar"><div class="sidebar-header"><div class="sidebar-controls"><input type="text" class="sidebar-search" id="tree-search" placeholder="Search..."></div><div class="sidebar-filters"><button class="filter-btn active" type="button">Default</button><button class="filter-btn" type="button">No-tools</button><button class="filter-btn" type="button">User</button><button class="filter-btn" type="button">Labeled</button><button class="filter-btn" type="button">All</button><button class="sidebar-close" id="sidebar-close" title="Close" type="button">✕</button></div></div><div class="tree-container" id="tree-container"><%= sidebar_entries.map { |entry| render_tree_node(entry) }.join %></div><div class="tree-status" id="tree-status"><%= sidebar_entries.length %> / <%= sidebar_entries.length %> entries</div></aside>
@@ -0,0 +1,6 @@
1
+ <%
2
+ call = tool.call
3
+ result = tool.result_entry
4
+ args = tool.input
5
+ command = args[:command] || args["command"] || pretty_json(args)
6
+ %><div class="tool-execution <%= result ? "success" : "pending" %>"><div class="tool-command">$ <%= h(presence(call[:name]) || command) %></div><% if fetch_tool_call?(call) %><div class="tool-output"><pre><%= h(pretty_tool_arguments(call)) %></pre></div><% elsif present?(args) %><div class="tool-output"><pre><%= h(pretty_json(args)) %></pre></div><% end %><% if result %><% images = tool_result_images(result) %><% if images.any? %><div class="tool-images"><% images.each do |image| %><img class="tool-image" src="<%= h(image_src(image)) %>" alt="<%= h("#{call[:name]} generated image") %>"><% end %></div><% end %><% rows = fetch_tool_call?(call) ? fetch_tool_result_rows(result) : [] %><% if rows.any? %><%= render_fetch_tool_table(rows) %><% else %><% output = tool_result_text(call, result) %><% if present?(output) %><div class="tool-output"><pre><%= h(output) %></pre></div><% end %><% end %><% end %></div>