token-lens 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.
@@ -0,0 +1,667 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module TokenLens
6
+ module Renderer
7
+ class Html
8
+ ROW_HEIGHT = 32
9
+ COORD_WIDTH = 1200 # logical coordinate space (matches Layout)
10
+ FONT_SIZE = 13
11
+ MIN_LABEL_PCT = 3.0 # hide label if bar is narrower than 3% of canvas
12
+
13
+ LEGEND_ITEMS = [
14
+ ["bar-c-human", "User prompt"],
15
+ ["bar-c-task", "Task callback"],
16
+ ["bar-c-assistant", "Assistant response"],
17
+ ["bar-c-tool", "Tool call"],
18
+ ["bar-c-sidechain", "Subagent turn"]
19
+ ].freeze
20
+
21
+ CONTEXT_LIMIT = 200_000 # all current Claude models
22
+
23
+ def render(nodes)
24
+ all_flat = flatten(nodes)
25
+ all = all_flat.reject { |n| n[:w] <= 1 }
26
+ max_depth = all.map { |n| n[:depth] }.max || 0
27
+ flame_height = (max_depth + 2) * ROW_HEIGHT # +1 for TOTAL bar at bottom
28
+ total_top = (max_depth + 1) * ROW_HEIGHT
29
+ total_tokens = nodes.sum { |n| n[:subtree_tokens] }
30
+ total_cost = nodes.sum { |n| n[:subtree_cost] }
31
+ total_tip = escape_js(escape_html(total_summary(all)))
32
+ @reread_files = build_reread_map(all)
33
+ @thread_count = nodes.length
34
+ @thread_numbers = {}
35
+ all.select { |n| n[:depth] == 0 }.each_with_index { |n, i| @thread_numbers[n[:token].uuid] = i + 1 }
36
+ @agent_labels = {}
37
+ all.each do |n|
38
+ id = n[:token].agent_id
39
+ next unless id && !@agent_labels.key?(id)
40
+ @agent_labels[id] = "A#{@agent_labels.size + 1}"
41
+ end
42
+ token_total_lbl = "TOTAL &middot; #{fmt(total_tokens)} tokens"
43
+ cost_total_lbl = "TOTAL &middot; #{fmt_cost(total_cost)}"
44
+
45
+ <<~HTML
46
+ <!DOCTYPE html>
47
+ <html data-theme="dark">
48
+ <head>
49
+ <meta charset="utf-8">
50
+ <title>Token Lens · Brickell Research</title>
51
+ <style>
52
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;1,400&display=swap');
53
+ #{css}
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="header">
58
+ <div>
59
+ <div class="summary">#{summary_text(all)}</div>
60
+ <div class="legend">#{legend_html}</div>
61
+ </div>
62
+ <div class="header-btns">
63
+ <button class="theme-btn" id="theme-btn" onclick="toggleTheme()">&#x25D0; Light</button>
64
+ <button class="summary-btn" id="summary-btn" onclick="toggleSummary()">&#x2261; Summary</button>
65
+ <button class="cost-btn" id="cost-btn" onclick="toggleCostView()">$ Cost view</button>
66
+ <button class="reset-btn" id="reset-btn" onclick="unzoom()">&#x21A9; Reset zoom</button>
67
+ </div>
68
+ </div>
69
+ <div class="spacer"></div>
70
+ <div class="flame" style="height:#{flame_height}px">
71
+ <div class="bar total-bar" style="left:0%;width:100%;top:#{total_top}px" data-ox="0" data-ow="#{COORD_WIDTH}" data-cx="0" data-cw="#{COORD_WIDTH}" onmouseover="tip('#{total_tip}')" onmouseout="tip('')" onclick="unzoom()"><span class="lbl total-lbl" id="total-lbl" data-token-text="#{token_total_lbl}" data-cost-text="#{cost_total_lbl}">#{token_total_lbl}</span></div>
72
+ #{all.map { |n| bar_html(n) }.join("\n")}
73
+ </div>
74
+ <div id="ftip" class="floattip"></div>
75
+ <div class="tip" id="tip">&nbsp;</div>
76
+ #{session_summary_html(all_flat)}
77
+ <script>
78
+ #{js}
79
+ </script>
80
+ </body>
81
+ </html>
82
+ HTML
83
+ end
84
+
85
+ private
86
+
87
+ def flatten(nodes)
88
+ nodes.flat_map { |n| [n, *flatten(n[:children])] }
89
+ end
90
+
91
+ def pct(val)
92
+ (val.to_f / COORD_WIDTH * 100).round(4)
93
+ end
94
+
95
+ def bar_html(node)
96
+ t = node[:token]
97
+ lbl = escape_html(label(node))
98
+ clbl = cost_label(node)
99
+ tip = escape_html(tooltip(node))
100
+ left = pct(node[:x])
101
+ width = pct(node[:w])
102
+ lbl_hidden = (width < MIN_LABEL_PCT) ? " style=\"display:none\"" : ""
103
+ extra_class = " #{color_class(node)}"
104
+ extra_class += " bar-reread" if reread_bar?(node)
105
+ extra_class += " bar-compaction" if t.is_compaction
106
+ extra_class += " bar-pressure" if !t.is_compaction && context_pressure?(node)
107
+ ftip = escape_html(token_summary(node))
108
+ mouseover = if t.is_task_notification?
109
+ summary = t.task_notification_summary || "Task callback"
110
+ "tip('#{escape_js(tip)}','#{escape_html(escape_js(summary))}')"
111
+ elsif t.is_human_prompt?
112
+ "tip('#{escape_js(tip)}','#{escape_html(escape_js(t.human_text))}')"
113
+ else
114
+ "tip('#{escape_js(tip)}')"
115
+ end
116
+ badge = reread_bar?(node) ? "<span class=\"warn-badge\">\u26a0</span>" : ""
117
+ <<~HTML.chomp
118
+ <div class="bar#{extra_class}" style="left:#{left}%;width:#{width}%;top:#{node[:y]}px" data-ox="#{node[:x]}" data-ow="#{node[:w]}" data-cx="#{node[:cost_x]}" data-cw="#{node[:cost_w]}" data-token-lbl="#{lbl}" data-cost-lbl="#{clbl}" data-ftip="#{ftip}" onmouseover="#{mouseover}" onmouseout="tip('')" onclick="zoom(this)"><span class="lbl"#{lbl_hidden}>#{lbl}</span>#{badge}</div>
119
+ HTML
120
+ end
121
+
122
+ def build_reread_map(all)
123
+ counts = Hash.new(0)
124
+ all.each do |node|
125
+ node[:token].tool_uses.each do |tu|
126
+ next unless %w[Read Write Edit].include?(tu["name"])
127
+ path = tu.dig("input", "file_path").to_s
128
+ counts[path] += 1 unless path.empty?
129
+ end
130
+ end
131
+ counts.select { |_, v| v > 1 }
132
+ end
133
+
134
+ def token_summary(node)
135
+ t = node[:token]
136
+ tok = fmt(node[:subtree_tokens])
137
+ cost = (node[:subtree_cost] > 0) ? " \u00b7 #{fmt_cost(node[:subtree_cost])}" : ""
138
+ if t.is_task_notification?
139
+ summary = t.task_notification_summary || "Task callback"
140
+ name = summary.match(/Agent "([^"]+)"/)&.[](1) || summary
141
+ "\u21a9 #{name} \u00b7 #{tok} tokens#{cost}"
142
+ elsif t.is_human_prompt?
143
+ num = @thread_numbers&.[](t.uuid)
144
+ num ? "Thread #{num} \u00b7 #{tok} tokens#{cost}" : "#{tok} tokens#{cost}"
145
+ elsif t.is_sidechain
146
+ "[#{model_short(t.model)}] #{tok} tokens#{cost}"
147
+ else
148
+ "#{tok} tokens#{cost}"
149
+ end
150
+ end
151
+
152
+ def reread_bar?(node)
153
+ node[:token].tool_uses.any? do |tu|
154
+ next unless %w[Read Write Edit].include?(tu["name"])
155
+ path = tu.dig("input", "file_path").to_s
156
+ @reread_files.key?(path)
157
+ end
158
+ end
159
+
160
+ def tool_result_tokens(node)
161
+ return 0 unless node[:token].tool_uses.any?
162
+ user_child = node[:children].find { |c| c[:token].role == "user" && c[:token].tool_results.any? }
163
+ return 0 unless user_child
164
+ chars = node[:token].tool_uses.sum do |tu|
165
+ tr = user_child[:token].tool_results.find { |r| r["tool_use_id"] == tu["id"] }
166
+ next 0 unless tr
167
+ Array(tr["content"]).sum { |c| c.is_a?(Hash) ? c.dig("text").to_s.length : c.to_s.length }
168
+ end
169
+ chars / 4
170
+ end
171
+
172
+ def summary_text(all)
173
+ assistant_nodes = all.select { |n| n[:token].role == "assistant" }
174
+ prompts = all.count { |n| n[:token].is_human_prompt? && !n[:token].is_task_notification? }
175
+ tasks = all.count { |n| n[:token].is_task_notification? }
176
+ turns = assistant_nodes.count { |n| !n[:token].is_sidechain }
177
+ sub = assistant_nodes.count { |n| n[:token].is_sidechain }
178
+ marginal = assistant_nodes.sum { |n| n[:token].marginal_input_tokens }
179
+ cached = assistant_nodes.sum { |n| n[:token].cache_read_tokens }
180
+ cache_new = assistant_nodes.sum { |n| n[:token].cache_creation_tokens }
181
+ raw_input = assistant_nodes.sum { |n| n[:token].input_tokens }
182
+ output = assistant_nodes.sum { |n| n[:token].output_tokens }
183
+ total_cost = assistant_nodes.sum { |n| n[:token].cost_usd }
184
+ total_input = raw_input + cached + cache_new
185
+ hit_rate = (total_input > 0 && cached > 0) ? (cached.to_f / total_input * 100).round(0).to_i : nil
186
+ parts = []
187
+ parts << "#{@thread_count} threads" if @thread_count&.> 1
188
+ parts << "#{prompts} #{"prompt".then { |w| (prompts == 1) ? w : "#{w}s" }}"
189
+ parts << "#{tasks} #{"task callback".then { |w| (tasks == 1) ? w : "#{w}s" }}" if tasks > 0
190
+ parts << "#{turns} main #{"turn".then { |w| (turns == 1) ? w : "#{w}s" }}"
191
+ parts << "#{sub} subagent #{"turn".then { |w| (sub == 1) ? w : "#{w}s" }}" if sub > 0
192
+ parts << "fresh input: #{fmt(marginal)}" if marginal > 0
193
+ parts << "cached input: #{fmt(cached)}" if cached > 0
194
+ parts << "written to cache: #{fmt(cache_new)}" if cache_new > 0
195
+ parts << "cache hit: #{hit_rate}%" if hit_rate
196
+ parts << "output: #{fmt(output)}" if output > 0
197
+ parts << fmt_cost(total_cost) if total_cost > 0
198
+ parts.join(" &middot; ")
199
+ end
200
+
201
+ def total_summary(all)
202
+ assistant_nodes = all.select { |n| n[:token].role == "assistant" }
203
+ marginal = assistant_nodes.sum { |n| n[:token].marginal_input_tokens }
204
+ cached = assistant_nodes.sum { |n| n[:token].cache_read_tokens }
205
+ cache_new = assistant_nodes.sum { |n| n[:token].cache_creation_tokens }
206
+ output = assistant_nodes.sum { |n| n[:token].output_tokens }
207
+ total_cost = assistant_nodes.sum { |n| n[:token].cost_usd }
208
+ parts = []
209
+ parts << "fresh input: #{fmt(marginal)}" if marginal > 0
210
+ parts << "cached input: #{fmt(cached)}" if cached > 0
211
+ parts << "written to cache: #{fmt(cache_new)}" if cache_new > 0
212
+ parts << "output: #{fmt(output)}" if output > 0
213
+ parts << "cost: #{fmt_cost(total_cost)}" if total_cost > 0
214
+ parts.join(" | ")
215
+ end
216
+
217
+ def legend_html
218
+ LEGEND_ITEMS.map { |css_class, lbl|
219
+ %(<span class="legend-item"><span class="legend-swatch #{css_class}"></span>#{lbl}</span>)
220
+ }.join
221
+ end
222
+
223
+ def color_class(node)
224
+ t = node[:token]
225
+ return "bar-c-task" if t.is_task_notification?
226
+ return "bar-c-sidechain" if t.is_sidechain
227
+ return "bar-c-human" if t.is_human_prompt?
228
+ case t.role
229
+ when "user" then "bar-c-user"
230
+ when "assistant"
231
+ t.tool_uses.any? ? "bar-c-tool" : "bar-c-assistant"
232
+ else "bar-c-user"
233
+ end
234
+ end
235
+
236
+ def label(node)
237
+ t = node[:token]
238
+ if t.is_task_notification?
239
+ summary = t.task_notification_summary || "Task callback"
240
+ name = summary.match(/Agent "([^"]+)"/)&.[](1) || summary
241
+ "\u21a9 #{name}"
242
+ elsif t.is_human_prompt?
243
+ t.human_text
244
+ elsif t.role == "assistant" && t.tool_uses.any?
245
+ uses = t.tool_uses
246
+ tool_str = if uses.length == 1
247
+ brief = tool_brief(uses.first)
248
+ brief.empty? ? uses.first["name"] : "#{uses.first["name"]}: #{brief}"
249
+ else
250
+ uses.map { |u| u["name"] }.join(", ")
251
+ end
252
+ badge = t.is_sidechain && t.agent_id && @agent_labels&.[](t.agent_id)
253
+ badge ? "[#{badge}] #{tool_str}" : tool_str
254
+ elsif t.role == "assistant"
255
+ if t.is_sidechain
256
+ agent_lbl = t.agent_id && @agent_labels&.[](t.agent_id)
257
+ prefix = agent_lbl ? "[#{model_short(t.model)} \u00b7 #{agent_lbl}] " : "[#{model_short(t.model)}] "
258
+ else
259
+ prefix = ""
260
+ end
261
+ text = t.content.find { |c| c.is_a?(Hash) && c["type"] == "text" }&.dig("text").to_s.strip
262
+ (text.length > 0) ? "#{prefix}#{text}" : "#{prefix}response \u00b7 out: #{fmt(t.output_tokens)}"
263
+ else
264
+ t.role
265
+ end
266
+ end
267
+
268
+ def tooltip(node)
269
+ t = node[:token]
270
+ parts = []
271
+ if t.is_human_prompt?
272
+ # tip bar shows only the prompt text (via 2nd arg); no redundant stats here
273
+ else
274
+ parts << "#{fmt(node[:subtree_tokens])} tokens"
275
+ parts << t.model if t.model
276
+ parts << "fresh input: #{fmt(t.marginal_input_tokens)}" if t.marginal_input_tokens > 0
277
+ parts << "cached input: #{fmt(t.cache_read_tokens)}" if t.cache_read_tokens > 0
278
+ parts << "written to cache: #{fmt(t.cache_creation_tokens)}" if t.cache_creation_tokens > 0
279
+ parts << "output: #{fmt(t.output_tokens)}"
280
+ parts << "cost: #{fmt_cost(t.cost_usd)}" if t.cost_usd > 0
281
+ t.tool_uses.each { |tool| parts << tool_detail(tool) unless tool_detail(tool).empty? }
282
+ result_tok = tool_result_tokens(node)
283
+ parts << "result: ~#{fmt(result_tok)} tokens" if result_tok > 0
284
+ parts << "subagent" if t.is_sidechain
285
+ parts << "agent: #{t.agent_id}" if t.agent_id
286
+ t.tool_uses.each do |tu|
287
+ next unless %w[Read Write Edit].include?(tu["name"])
288
+ path = tu.dig("input", "file_path").to_s
289
+ count = @reread_files&.[](path)
290
+ parts << "⚠ #{File.basename(path)} accessed #{count}x in session" if count
291
+ end
292
+ end
293
+ parts.join(" | ")
294
+ end
295
+
296
+ def tool_brief(tool)
297
+ input = tool["input"] || {}
298
+ case tool["name"]
299
+ when "Bash"
300
+ (input["command"] || "").strip.sub(/\Asource[^\n&]+&&\s*rvm[^\n&]+&&\s*/, "")
301
+ when "Read", "Write", "Edit"
302
+ File.basename(input["file_path"].to_s)
303
+ when "Glob" then input["pattern"].to_s
304
+ when "Grep" then input["pattern"].to_s
305
+ when "Agent" then input["description"].to_s
306
+ when "WebSearch" then input["query"].to_s
307
+ when "WebFetch" then input["url"].to_s.split("/").last(2).join("/")
308
+ else ""
309
+ end
310
+ end
311
+
312
+ def tool_detail(tool)
313
+ input = tool["input"] || {}
314
+ case tool["name"]
315
+ when "Bash"
316
+ cmd = (input["command"] || "").strip.sub(/\Asource[^\n&]+&&\s*rvm[^\n&]+&&\s*/, "")
317
+ truncate(cmd, 100)
318
+ when "Read", "Write", "Edit" then input["file_path"].to_s
319
+ when "Glob" then "glob:#{input["pattern"]}"
320
+ when "Grep" then "grep:#{input["pattern"]}"
321
+ when "Agent" then truncate(input["prompt"].to_s, 100)
322
+ when "WebSearch" then "search:#{input["query"]}"
323
+ when "WebFetch" then input["url"].to_s
324
+ else ""
325
+ end
326
+ end
327
+
328
+ def model_short(model)
329
+ return "sub" unless model
330
+ %w[haiku sonnet opus].find { |f| model.include?(f) } || "sub"
331
+ end
332
+
333
+ def context_pressure?(node)
334
+ t = node[:token]
335
+ total = t.input_tokens + t.cache_read_tokens + t.cache_creation_tokens
336
+ total > CONTEXT_LIMIT * 0.7
337
+ end
338
+
339
+ def session_summary_html(all)
340
+ assistant_nodes = all.select { |n| n[:token].role == "assistant" }
341
+ return "" if assistant_nodes.empty?
342
+
343
+ prompts = all.count { |n| n[:token].is_human_prompt? }
344
+ turns = assistant_nodes.count { |n| !n[:token].is_sidechain }
345
+ sub = assistant_nodes.count { |n| n[:token].is_sidechain }
346
+ raw_input = assistant_nodes.sum { |n| n[:token].input_tokens }
347
+ cached = assistant_nodes.sum { |n| n[:token].cache_read_tokens }
348
+ cache_new = assistant_nodes.sum { |n| n[:token].cache_creation_tokens }
349
+ output = assistant_nodes.sum { |n| n[:token].output_tokens }
350
+ total_input = raw_input + cached + cache_new
351
+ hit_rate = (total_input > 0 && cached > 0) ? (cached.to_f / total_input * 100).round(1) : nil
352
+ total_cost = assistant_nodes.sum { |n| n[:token].cost_usd }
353
+ compactions = assistant_nodes.count { |n| n[:token].is_compaction }
354
+ pressure = assistant_nodes.count { |n| context_pressure?(n) }
355
+ models = assistant_nodes.map { |n| n[:token].model }.compact
356
+ .map { |m| model_short(m) }.uniq.join(", ")
357
+
358
+ timestamps = all.map { |n| n[:token].timestamp }.compact.sort
359
+ duration_str = if timestamps.length >= 2
360
+ begin
361
+ secs = (Time.parse(timestamps.last) - Time.parse(timestamps.first)).to_i
362
+ fmt_duration(secs)
363
+ rescue
364
+ nil
365
+ end
366
+ end
367
+
368
+ rows = []
369
+ rows << summary_stat("Prompts", prompts.to_s)
370
+ rows << summary_stat("Main turns", turns.to_s)
371
+ rows << summary_stat("Subagent turns", sub.to_s) if sub > 0
372
+ rows << summary_stat("Duration", duration_str) if duration_str
373
+ rows << summary_stat("Models", models) unless models.empty?
374
+ rows << summary_stat("Total cost", fmt_cost(total_cost))
375
+ rows << summary_stat("Cache hit rate", "#{hit_rate}%") if hit_rate
376
+ rows << summary_stat("Total input", "#{fmt(total_input)} tok")
377
+ rows << summary_stat("Total output", "#{fmt(output)} tok")
378
+ rows << summary_stat("Compaction events", compactions.to_s, warn: true) if compactions > 0
379
+ rows << summary_stat("High context turns", pressure.to_s, warn: true) if pressure > 0
380
+
381
+ <<~HTML
382
+ <div id="summary-panel" class="summary-panel" style="display:none">
383
+ <div class="summary-panel-title">Session Summary <button class="summary-close" onclick="toggleSummary()">&#x2715;</button></div>
384
+ <dl class="summary-dl">
385
+ #{rows.join("\n ")}
386
+ </dl>
387
+ </div>
388
+ HTML
389
+ end
390
+
391
+ def summary_stat(label, value, warn: false)
392
+ val_class = warn ? "summary-val summary-warn" : "summary-val"
393
+ "<dt class=\"summary-dt\">#{escape_html(label)}</dt><dd class=\"#{val_class}\">#{escape_html(value)}</dd>"
394
+ end
395
+
396
+ def fmt_duration(secs)
397
+ return "#{secs}s" if secs < 60
398
+ mins = secs / 60
399
+ rem = secs % 60
400
+ return "#{mins}m #{rem}s" if mins < 60
401
+ "#{mins / 60}h #{mins % 60}m"
402
+ end
403
+
404
+ def cost_label(node)
405
+ fmt_cost(node[:subtree_cost])
406
+ end
407
+
408
+ def fmt(n)
409
+ (n >= 1000) ? "#{(n / 1000.0).round(1)}k" : n.to_s
410
+ end
411
+
412
+ def fmt_cost(usd)
413
+ return "$0" if usd == 0
414
+ if usd >= 1.0
415
+ "$%.2f" % usd
416
+ elsif usd >= 0.01
417
+ "$%.3f" % usd
418
+ else
419
+ "$%.4f" % usd
420
+ end
421
+ end
422
+
423
+ def truncate(str, len)
424
+ (str.length > len) ? "#{str[0, len]}…" : str
425
+ end
426
+
427
+ def escape_html(str)
428
+ str.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
429
+ end
430
+
431
+ def escape_js(str)
432
+ str.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }.gsub("\n", "\\n").gsub("\r", "\\r")
433
+ end
434
+
435
+ def css
436
+ <<~CSS
437
+ :root {
438
+ --bar-human: #FF1493;
439
+ --bar-task: #FFB020;
440
+ --bar-assistant: #00CED1;
441
+ --bar-tool: #00A8E8;
442
+ --bar-sidechain: #C97BFF;
443
+ --bar-user: #1a1a2e;
444
+ --bg: #000000;
445
+ --surface: #0a0a14;
446
+ --border: #1a1a2e;
447
+ --text: #c8d4dc;
448
+ --text-dim: #8892a0;
449
+ --bar-text: #000000;
450
+ --bar-border: #000000;
451
+ --shadow: rgba(0,0,0,0.8);
452
+ --accent: #FF1493;
453
+ --accent2: #00CED1;
454
+ --accent-faint: rgba(255,20,147,0.6);
455
+ }
456
+ :root[data-theme="light"] {
457
+ --bar-user: #c8ccd8;
458
+ --bg: #f8f8fc;
459
+ --surface: #ffffff;
460
+ --border: #d8dae8;
461
+ --text: #1a1a2e;
462
+ --text-dim: #5a6070;
463
+ --bar-border: #e0e0ec;
464
+ --shadow: rgba(0,0,0,0.12);
465
+ --accent-faint: rgba(255,20,147,0.45);
466
+ }
467
+ * { box-sizing: border-box; margin: 0; padding: 0; }
468
+ html, body { height: 100%; }
469
+ body { background: var(--bg); font-family: 'JetBrains Mono', 'Cascadia Mono', 'Fira Code', ui-monospace, monospace; font-size: #{FONT_SIZE}px; display: flex; flex-direction: column; min-height: 100vh; color: var(--text); }
470
+ .header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; border-bottom: 1px solid var(--border); }
471
+ .summary { color: var(--text-dim); font-size: 11px; line-height: 20px; }
472
+ .header-btns { display: flex; gap: 6px; align-items: center; }
473
+ .cost-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: 3px; padding: 2px 8px; font-size: 10px; cursor: pointer; font-family: inherit; }
474
+ .cost-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent2); }
475
+ .cost-btn.active { border-color: var(--accent2); color: var(--accent2); }
476
+ .reset-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: 3px; padding: 2px 8px; font-size: 10px; cursor: pointer; display: none; font-family: inherit; }
477
+ .reset-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent); }
478
+ .theme-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: 3px; padding: 2px 8px; font-size: 10px; cursor: pointer; font-family: inherit; }
479
+ .theme-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent2); }
480
+ .spacer { flex: 1; }
481
+ .flame { position: relative; width: 100%; }
482
+ .bar {
483
+ position: absolute;
484
+ height: #{ROW_HEIGHT - 2}px;
485
+ border-radius: 1px;
486
+ border-right: 1px solid var(--bar-border);
487
+ border-bottom: 2px solid var(--bar-border);
488
+ cursor: pointer;
489
+ overflow: hidden;
490
+ user-select: none;
491
+ }
492
+ .bar:hover { filter: brightness(1.15) saturate(1.1); }
493
+ .bar-reread { box-shadow: inset 0 -2px 0 var(--accent); }
494
+ .bar-compaction { box-shadow: 0 0 8px 3px rgba(255,20,147,0.7), inset 0 0 0 1px var(--accent); }
495
+ .bar-pressure { box-shadow: 0 0 5px 1px rgba(255,20,147,0.4); }
496
+ .warn-badge { position: absolute; top: 1px; right: 3px; font-size: 10px; line-height: 1; color: var(--accent); pointer-events: none; font-weight: 600; }
497
+ .bar-c-human { background: var(--bar-human); }
498
+ .bar-c-task { background: var(--bar-task); }
499
+ .bar-c-assistant { background: var(--bar-assistant); }
500
+ .bar-c-tool { background: var(--bar-tool); }
501
+ .bar-c-sidechain { background: var(--bar-sidechain); }
502
+ .bar-c-user { background: var(--bar-user); }
503
+ .lbl {
504
+ display: block;
505
+ padding: 0 5px;
506
+ line-height: #{ROW_HEIGHT - 4}px;
507
+ overflow: hidden;
508
+ text-overflow: ellipsis;
509
+ white-space: nowrap;
510
+ color: var(--bar-text);
511
+ font-size: #{FONT_SIZE - 1}px;
512
+ font-weight: 600;
513
+ pointer-events: none;
514
+ }
515
+ .tip { padding: 4px 8px; font-size: 11px; min-height: 22px; display: flex; align-items: baseline; flex-wrap: wrap; gap: 0; border-top: 1px solid var(--border); background: var(--bg); }
516
+ .tip-sep { color: var(--border); padding: 0 6px; }
517
+ .tip-tokens { color: var(--text); font-weight: 600; }
518
+ .tip-model { color: var(--text-dim); }
519
+ .tip-label { color: var(--text-dim); }
520
+ .tip-code { color: var(--accent2); }
521
+ .tip-warn { color: var(--accent); }
522
+ .tip-prompt { flex: 1; min-width: 0; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
523
+ .total-bar { background: var(--surface); border: 1px solid var(--border); border-radius: 1px; cursor: pointer; }
524
+ .total-lbl { color: var(--text-dim) !important; font-weight: 600; letter-spacing: 0.04em; font-size: 11px; }
525
+ .legend { padding: 2px 8px 6px; display: flex; gap: 14px; flex-wrap: wrap; }
526
+ .legend-item { display: flex; align-items: center; gap: 5px; color: var(--text-dim); font-size: 10px; }
527
+ .legend-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 1px; flex-shrink: 0; }
528
+ .floattip { position: fixed; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 3px; padding: 3px 8px; font-size: 11px; font-family: inherit; pointer-events: none; display: none; white-space: nowrap; z-index: 1000; box-shadow: 0 4px 16px var(--shadow); }
529
+ .summary-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: 3px; padding: 2px 8px; font-size: 10px; cursor: pointer; font-family: inherit; }
530
+ .summary-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent); }
531
+ .summary-btn.active { border-color: var(--accent); color: var(--accent); }
532
+ .summary-panel { position: fixed; right: 0; top: 0; height: 100%; width: 260px; background: var(--surface); border-left: 1px solid var(--border); z-index: 500; overflow-y: auto; padding: 16px; box-shadow: -8px 0 24px var(--shadow); }
533
+ .summary-panel-title { color: var(--accent); font-size: 12px; font-weight: 600; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; letter-spacing: 0.04em; }
534
+ .summary-close { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 0 2px; line-height: 1; font-family: inherit; }
535
+ .summary-close:hover { color: var(--accent); }
536
+ .summary-dl { display: grid; grid-template-columns: 1fr auto; gap: 8px 16px; }
537
+ .summary-dt { color: var(--text-dim); font-size: 10px; align-self: center; }
538
+ .summary-val { color: var(--text); font-size: 11px; font-weight: 600; text-align: right; align-self: center; }
539
+ .summary-warn { color: var(--accent); }
540
+ CSS
541
+ end
542
+
543
+ def js
544
+ w = COORD_WIDTH
545
+ min_pct = MIN_LABEL_PCT
546
+ <<~JS
547
+ (function() {
548
+ var W = #{w}, MIN_PCT = #{min_pct};
549
+ var costMode = false;
550
+ function bars() { return Array.from(document.querySelectorAll('.bar:not(.total-bar)')); }
551
+ function applyBar(el, nx, nw) {
552
+ if (nx + nw <= 0 || nx >= W) {
553
+ el.style.display = 'none';
554
+ } else {
555
+ el.style.display = '';
556
+ el.style.left = (nx / W * 100) + '%';
557
+ el.style.width = Math.max(nw, 1) / W * 100 + '%';
558
+ var lbl = el.querySelector('.lbl');
559
+ if (lbl) lbl.style.display = (nw / W * 100 < MIN_PCT) ? 'none' : '';
560
+ }
561
+ }
562
+ function resetBtn() { return document.getElementById('reset-btn'); }
563
+ window.toggleTheme = function() {
564
+ var root = document.documentElement;
565
+ var isLight = root.getAttribute('data-theme') === 'light';
566
+ root.setAttribute('data-theme', isLight ? 'dark' : 'light');
567
+ var btn = document.getElementById('theme-btn');
568
+ if (btn) btn.textContent = isLight ? '\u25D0 Light' : '\u25D1 Dark';
569
+ };
570
+ window.toggleSummary = function() {
571
+ var p = document.getElementById('summary-panel');
572
+ var btn = document.getElementById('summary-btn');
573
+ if (!p) return;
574
+ var shown = p.style.display !== 'none';
575
+ p.style.display = shown ? 'none' : 'block';
576
+ if (btn) btn.classList.toggle('active', !shown);
577
+ };
578
+ window.toggleCostView = function() {
579
+ costMode = !costMode;
580
+ bars().forEach(function(b) {
581
+ b.removeAttribute('ox');
582
+ b.removeAttribute('ow');
583
+ var nx = costMode ? +b.getAttribute('data-cx') : +b.getAttribute('data-ox');
584
+ var nw = costMode ? +b.getAttribute('data-cw') : +b.getAttribute('data-ow');
585
+ applyBar(b, nx, nw);
586
+ var lbl = b.querySelector('.lbl');
587
+ if (lbl) {
588
+ var text = b.getAttribute(costMode ? 'data-cost-lbl' : 'data-token-lbl');
589
+ if (text !== null) lbl.textContent = text;
590
+ }
591
+ });
592
+ var tl = document.getElementById('total-lbl');
593
+ if (tl) tl.innerHTML = tl.getAttribute(costMode ? 'data-cost-text' : 'data-token-text');
594
+ var cb = document.getElementById('cost-btn');
595
+ if (cb) { cb.textContent = costMode ? '# Token view' : '$ Cost view'; cb.classList.toggle('active', costMode); }
596
+ var rb = resetBtn(); if (rb) rb.style.display = 'none';
597
+ };
598
+ window.zoom = function(el) {
599
+ var fx = costMode ? +el.getAttribute('data-cx') : +el.getAttribute('data-ox');
600
+ var fw = costMode ? +el.getAttribute('data-cw') : +el.getAttribute('data-ow');
601
+ if (fw >= W - 1) { unzoom(); return; }
602
+ bars().forEach(function(b) {
603
+ if (!b.getAttribute('ox')) {
604
+ b.setAttribute('ox', costMode ? +b.getAttribute('data-cx') : +b.getAttribute('data-ox'));
605
+ b.setAttribute('ow', costMode ? +b.getAttribute('data-cw') : +b.getAttribute('data-ow'));
606
+ }
607
+ applyBar(b, (+b.getAttribute('ox') - fx) / fw * W, +b.getAttribute('ow') / fw * W);
608
+ });
609
+ var btn = resetBtn(); if (btn) btn.style.display = 'inline-block';
610
+ };
611
+ window.unzoom = function() {
612
+ bars().forEach(function(b) {
613
+ var ox = b.getAttribute('ox');
614
+ if (ox) {
615
+ applyBar(b, +ox, +b.getAttribute('ow'));
616
+ b.removeAttribute('ox');
617
+ b.removeAttribute('ow');
618
+ }
619
+ });
620
+ var btn = resetBtn(); if (btn) btn.style.display = 'none';
621
+ };
622
+ function esc(t) { return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
623
+ window.tip = function(s, prompt) {
624
+ var el = document.getElementById('tip');
625
+ if (!el) return;
626
+ if (!s && !prompt) { el.innerHTML = '&nbsp;'; return; }
627
+ var sep = '<span class="tip-sep">\u00b7</span>';
628
+ var parts = s ? s.split(' | ').filter(Boolean) : [];
629
+ var html = parts.map(function(p, i) {
630
+ var e = esc(p);
631
+ if (p.charAt(0) === '\u26a0') return '<span class="tip-warn">' + e + '</span>';
632
+ if (i === 0 && p.indexOf('tokens') !== -1) return '<span class="tip-tokens">' + e + '</span>';
633
+ if (p.indexOf('claude-') !== -1 || /^(haiku|sonnet|opus)/.test(p)) return '<span class="tip-model">' + e + '</span>';
634
+ if (/^[^:]*:\s/.test(p)) return '<span class="tip-label">' + e + '</span>';
635
+ return '<span class="tip-code">' + e + '</span>';
636
+ }).join(sep);
637
+ if (prompt) html += '<span class="tip-prompt">' + esc(prompt) + '</span>';
638
+ el.innerHTML = html;
639
+ };
640
+ var mx = 0, my = 0;
641
+ document.addEventListener('mousemove', function(e) {
642
+ mx = e.clientX; my = e.clientY;
643
+ var ft = document.getElementById('ftip');
644
+ if (ft && ft.style.display !== 'none') {
645
+ ft.style.left = (mx + 14) + 'px';
646
+ ft.style.top = (my - 38) + 'px';
647
+ }
648
+ });
649
+ document.addEventListener('mouseover', function(e) {
650
+ var bar = e.target.closest && e.target.closest('.bar:not(.total-bar)');
651
+ var ft = document.getElementById('ftip');
652
+ if (!ft) return;
653
+ if (bar) {
654
+ var d = bar.getAttribute('data-ftip');
655
+ if (d) { ft.textContent = d; ft.style.display = 'block'; ft.style.left = (mx + 14) + 'px'; ft.style.top = (my - 38) + 'px'; }
656
+ }
657
+ });
658
+ document.addEventListener('mouseout', function(e) {
659
+ var bar = e.target.closest && e.target.closest('.bar:not(.total-bar)');
660
+ if (bar) { var ft = document.getElementById('ftip'); if (ft) ft.style.display = 'none'; }
661
+ });
662
+ })();
663
+ JS
664
+ end
665
+ end
666
+ end
667
+ end