token-lens 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e268e0550d46641067f1f99507b60d92251a4b58d025b39b8b3f90c428eb47a
4
- data.tar.gz: 03243e21fd60fa99443b35a9e384fbb84d1fcf7c00072c297fa5fb0d46ae83a7
3
+ metadata.gz: d15dc78e85a94ff7c3e4c22388e74a9084543cbe00936dcc26c155f2c1e07191
4
+ data.tar.gz: e66a0b7ba2753b5129f76c5900a8c81dcb6b51bdfb2a17b34e4a9d4c9925a7cc
5
5
  SHA512:
6
- metadata.gz: 88af830c547a6ef86aa5b0fdf763822df9a9ca134b5d304769ba66f97585c731012c90d9498a599bac64abdace620fbec9f113b9c9e476acf301c4a18dfe7bc0
7
- data.tar.gz: d4c8be308c772f5c117d79a5b82d4939b5f2f45776be127f3ec6bc5bb73487055be5c29b92c30b12e848fad89f6e4f9b387ab648ae055a27e282639b66fec42a
6
+ metadata.gz: 9f5cdbfe0e769a8cac9c5d610336e66d1a8f9d07b0767bd31d3250fbe47425597eb89f5bc9e04e7a3ab8f97e2effe7c90d073f3fd8e02236ca4b210d6e81f6a3
7
+ data.tar.gz: 3fe4f1b44190dd712cf6c4353c3155fb0684a3192e987436a26611005a9775be23a09d0ddd34c2dc2845e678435ce0f2842068b335b245dadef994d17a3c009b
@@ -20,9 +20,10 @@ module TokenLens
20
20
  warn "Rendering #{path}"
21
21
  tree = Parser.new(file_path: path).parse
22
22
  tree = Renderer::Reshaper.new.reshape(tree)
23
+ tree.sort_by! { |n| n[:token].timestamp || "" }
23
24
  Renderer::Annotator.new.annotate(tree)
24
- Renderer::Layout.new.layout(tree)
25
- html = Renderer::Html.new.render(tree)
25
+ canvas_width = Renderer::Layout.new.layout(tree)
26
+ html = Renderer::Html.new(canvas_width: canvas_width).render(tree)
26
27
  File.write(@output, html)
27
28
  warn "Wrote #{@output}"
28
29
  end
@@ -35,7 +36,7 @@ module TokenLens
35
36
  saved = sessions.glob("*.json").max_by(&:mtime)
36
37
  return saved if saved
37
38
  warn "No saved captures found — reading active Claude Code session directly"
38
- Session.latest_jsonl
39
+ Session.active_or_latest_jsonl
39
40
  end
40
41
  end
41
42
  end
@@ -6,16 +6,21 @@ module TokenLens
6
6
  module Renderer
7
7
  class Html
8
8
  ROW_HEIGHT = 32
9
- COORD_WIDTH = 1200 # logical coordinate space (matches Layout)
9
+ COORD_WIDTH = 1200 # default logical coordinate space
10
10
  FONT_SIZE = 13
11
- MIN_LABEL_PCT = 3.0 # hide label if bar is narrower than 3% of canvas
11
+ MIN_LABEL_PX = 60 # hide label if bar is narrower than 60px
12
+
13
+ def initialize(canvas_width: COORD_WIDTH)
14
+ @canvas_width = canvas_width
15
+ end
12
16
 
13
17
  LEGEND_ITEMS = [
14
18
  ["bar-c-human", "User prompt"],
15
19
  ["bar-c-task", "Task callback"],
16
20
  ["bar-c-assistant", "Assistant response"],
17
21
  ["bar-c-tool", "Tool call"],
18
- ["bar-c-sidechain", "Subagent turn"]
22
+ ["bar-c-sidechain", "Subagent turn"],
23
+ ["bar-compaction", "Compaction"]
19
24
  ].freeze
20
25
 
21
26
  CONTEXT_LIMIT = 200_000 # all current Claude models
@@ -28,7 +33,7 @@ module TokenLens
28
33
  total_top = (max_depth + 1) * ROW_HEIGHT
29
34
  total_tokens = nodes.sum { |n| n[:subtree_tokens] }
30
35
  total_cost = nodes.sum { |n| n[:subtree_cost] }
31
- total_tip = escape_js(escape_html(total_summary(all)))
36
+ total_tip = escape_js(escape_html(total_summary(all_flat)))
32
37
  @reread_files = build_reread_map(all)
33
38
  @thread_count = nodes.length
34
39
  @thread_numbers = {}
@@ -39,6 +44,8 @@ module TokenLens
39
44
  next unless id && !@agent_labels.key?(id)
40
45
  @agent_labels[id] = "A#{@agent_labels.size + 1}"
41
46
  end
47
+ assign_alternation(nodes)
48
+ @hm_count = nodes.length # overridden by heatmap_html after grouping
42
49
  token_total_lbl = "TOTAL · #{fmt(total_tokens)} tokens"
43
50
  cost_total_lbl = "TOTAL · #{fmt_cost(total_cost)}"
44
51
 
@@ -56,23 +63,27 @@ module TokenLens
56
63
  <body>
57
64
  <div class="header">
58
65
  <div>
59
- <div class="summary">#{summary_text(all)}</div>
60
- <div class="legend">#{legend_html}</div>
66
+ <div class="summary">#{summary_text(all_flat)}</div>
67
+ <div class="legend" id="legend" style="display:none">#{legend_html}</div>
61
68
  </div>
62
69
  <div class="header-btns">
63
70
  <button class="theme-btn" id="theme-btn" onclick="toggleTheme()">&#x25D0; Light</button>
64
71
  <button class="summary-btn" id="summary-btn" onclick="toggleSummary()">&#x2261; Summary</button>
65
72
  <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>
73
+ <button class="reset-btn" id="reset-btn" onclick="resetZoom()">&#x21A9; Reset zoom</button>
74
+ <button class="export-btn" onclick="exportSVG()">&#x2913; Export</button>
67
75
  </div>
68
76
  </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>
77
+ #{heatmap_html(nodes)}
78
+ <div id="hm-back" class="hm-back" style="display:none" onclick="closePrompt()">&#x2190; All prompts</div>
79
+ <div class="flame-wrap" id="flame-wrap" style="display:none">
80
+ <div class="flame" style="width:#{@canvas_width}px;height:#{flame_height}px">
81
+ <div class="bar total-bar" style="left:0%;width:100%;top:#{total_top}px" data-ox="0" data-ow="#{@canvas_width}" data-cx="0" data-cw="#{@canvas_width}" onmouseover="tip('#{total_tip}')" onmouseout="tip('')" onclick="if(hmActiveIdx<0)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
82
  #{all.map { |n| bar_html(n) }.join("\n")}
73
83
  </div>
84
+ </div>
74
85
  <div id="ftip" class="floattip"></div>
75
- <div class="tip" id="tip">&nbsp;</div>
86
+ <div id="tip" class="tip"><span class="tip-label">Hover for details &middot; Click to zoom</span></div>
76
87
  #{session_summary_html(all_flat)}
77
88
  <script>
78
89
  #{js}
@@ -89,7 +100,7 @@ module TokenLens
89
100
  end
90
101
 
91
102
  def pct(val)
92
- (val.to_f / COORD_WIDTH * 100).round(4)
103
+ (val.to_f / @canvas_width * 100).round(4)
93
104
  end
94
105
 
95
106
  def bar_html(node)
@@ -99,10 +110,11 @@ module TokenLens
99
110
  tip = escape_html(tooltip(node))
100
111
  left = pct(node[:x])
101
112
  width = pct(node[:w])
102
- lbl_hidden = (width < MIN_LABEL_PCT) ? " style=\"display:none\"" : ""
113
+ lbl_hidden = (node[:w] < MIN_LABEL_PX) ? " style=\"display:none\"" : ""
103
114
  extra_class = " #{color_class(node)}"
115
+ extra_class += " bar-alt" if node[:alt]
104
116
  extra_class += " bar-reread" if reread_bar?(node)
105
- extra_class += " bar-compaction" if t.is_compaction
117
+ # is_compaction on assistant turns is for summary counting only; color is set via color_class on the user prompt
106
118
  extra_class += " bar-pressure" if !t.is_compaction && context_pressure?(node)
107
119
  ftip = escape_html(token_summary(node))
108
120
  mouseover = if t.is_task_notification?
@@ -115,7 +127,7 @@ module TokenLens
115
127
  end
116
128
  badge = reread_bar?(node) ? "<span class=\"warn-badge\">\u26a0</span>" : ""
117
129
  <<~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>
130
+ <div class="bar#{extra_class}" style="left:#{left}%;width:calc(#{width}% + 1px);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
131
  HTML
120
132
  end
121
133
 
@@ -184,11 +196,19 @@ module TokenLens
184
196
  total_input = raw_input + cached + cache_new
185
197
  hit_rate = (total_input > 0 && cached > 0) ? (cached.to_f / total_input * 100).round(0).to_i : nil
186
198
  parts = []
199
+ timestamps = all.map { |n| n[:token].timestamp }.compact.sort
200
+ duration = begin
201
+ secs = (Time.parse(timestamps.last) - Time.parse(timestamps.first)).to_i
202
+ fmt_duration(secs) if timestamps.length >= 2 && secs > 0
203
+ rescue
204
+ nil
205
+ end
187
206
  parts << "#{@thread_count} threads" if @thread_count&.> 1
188
207
  parts << "#{prompts} #{"prompt".then { |w| (prompts == 1) ? w : "#{w}s" }}"
189
208
  parts << "#{tasks} #{"task callback".then { |w| (tasks == 1) ? w : "#{w}s" }}" if tasks > 0
190
209
  parts << "#{turns} main #{"turn".then { |w| (turns == 1) ? w : "#{w}s" }}"
191
210
  parts << "#{sub} subagent #{"turn".then { |w| (sub == 1) ? w : "#{w}s" }}" if sub > 0
211
+ parts << duration if duration
192
212
  parts << "fresh input: #{fmt(marginal)}" if marginal > 0
193
213
  parts << "cached input: #{fmt(cached)}" if cached > 0
194
214
  parts << "written to cache: #{fmt(cache_new)}" if cache_new > 0
@@ -214,6 +234,24 @@ module TokenLens
214
234
  parts.join(" | ")
215
235
  end
216
236
 
237
+ def thread_separators(all)
238
+ roots = all.select { |n| n[:depth] == 0 }
239
+ return "" if roots.size <= 1
240
+ # Emit a vertical line at the right edge of each thread (except the last)
241
+ roots[0..-2].map { |n|
242
+ right_x = n[:x] + n[:w]
243
+ pct_val = pct(right_x)
244
+ %(<div class="thread-sep" style="left:#{pct_val}%"></div>)
245
+ }.join("\n")
246
+ end
247
+
248
+ def assign_alternation(siblings)
249
+ siblings.each_with_index do |node, i|
250
+ node[:alt] = i.odd?
251
+ assign_alternation(node[:children])
252
+ end
253
+ end
254
+
217
255
  def legend_html
218
256
  LEGEND_ITEMS.map { |css_class, lbl|
219
257
  %(<span class="legend-item"><span class="legend-swatch #{css_class}"></span>#{lbl}</span>)
@@ -224,6 +262,7 @@ module TokenLens
224
262
  t = node[:token]
225
263
  return "bar-c-task" if t.is_task_notification?
226
264
  return "bar-c-sidechain" if t.is_sidechain
265
+ return "bar-compaction" if t.is_human_prompt? && t.human_text.start_with?("This session is being continued")
227
266
  return "bar-c-human" if t.is_human_prompt?
228
267
  case t.role
229
268
  when "user" then "bar-c-user"
@@ -350,7 +389,7 @@ module TokenLens
350
389
  total_input = raw_input + cached + cache_new
351
390
  hit_rate = (total_input > 0 && cached > 0) ? (cached.to_f / total_input * 100).round(1) : nil
352
391
  total_cost = assistant_nodes.sum { |n| n[:token].cost_usd }
353
- compactions = assistant_nodes.count { |n| n[:token].is_compaction }
392
+ compactions = all.count { |n| n[:token].is_human_prompt? && n[:token].human_text.start_with?("This session is being continued") }
354
393
  pressure = assistant_nodes.count { |n| context_pressure?(n) }
355
394
  models = assistant_nodes.map { |n| n[:token].model }.compact
356
395
  .map { |m| model_short(m) }.uniq.join(", ")
@@ -432,6 +471,96 @@ module TokenLens
432
471
  str.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }.gsub("\n", "\\n").gsub("\r", "\\r")
433
472
  end
434
473
 
474
+ def heatmap_color(value, min_val, max_val)
475
+ t = (max_val == min_val) ? 0.5 : ((value - min_val).to_f / (max_val - min_val))
476
+ t **= 0.7
477
+ r = (32 + (255 - 32) * t).round.clamp(0, 255)
478
+ g = (5 + (20 - 5) * t).round.clamp(0, 255)
479
+ b = (16 + (147 - 16) * t).round.clamp(0, 255)
480
+ "#%02x%02x%02x" % [r, g, b]
481
+ end
482
+
483
+ def heatmap_color_token(value, min_val, max_val)
484
+ t = (max_val == min_val) ? 0.5 : ((value - min_val).to_f / (max_val - min_val))
485
+ t **= 0.7
486
+ r = (7 + (0 - 7) * t).round.clamp(0, 255)
487
+ g = (48 + (206 - 48) * t).round.clamp(0, 255)
488
+ b = (48 + (209 - 48) * t).round.clamp(0, 255)
489
+ "#%02x%02x%02x" % [r, g, b]
490
+ end
491
+
492
+ def compaction_node?(node)
493
+ node[:token].is_human_prompt? && node[:token].human_text.start_with?("This session is being continued")
494
+ end
495
+
496
+ def heatmap_html(nodes)
497
+ # Merge each compaction node into the preceding group — it's overhead from that prompt
498
+ groups = []
499
+ nodes.each do |node|
500
+ if compaction_node?(node) && groups.any?
501
+ groups.last << node
502
+ else
503
+ groups << [node]
504
+ end
505
+ end
506
+ @hm_count = groups.length
507
+
508
+ group_tokens = groups.map { |g| g.sum { |n| n[:subtree_tokens] } }
509
+ group_costs = groups.map { |g| g.sum { |n| n[:subtree_cost] } }
510
+ min_tok, max_tok = group_tokens.min, group_tokens.max
511
+ min_cost, max_cost = group_costs.min, group_costs.max
512
+
513
+ cells = groups.each_with_index.map { |group, i|
514
+ primary = group.first
515
+ combined_tokens = group_tokens[i]
516
+ combined_cost = group_costs[i]
517
+ has_compaction = group.length > 1
518
+
519
+ color_cost = heatmap_color(combined_cost, min_cost, max_cost)
520
+ color_token = heatmap_color_token(combined_tokens, min_tok, max_tok)
521
+
522
+ # x/w spans all nodes in the group
523
+ ox = primary[:x]
524
+ ow = group.last[:x] + group.last[:w] - primary[:x]
525
+ cx = primary[:cost_x]
526
+ cw = group.last[:cost_x] + group.last[:cost_w] - primary[:cost_x]
527
+
528
+ num = @thread_numbers&.[](primary[:token].uuid)
529
+ tok_str = fmt(combined_tokens)
530
+ cost_str = (combined_cost > 0) ? " \u00b7 #{fmt_cost(combined_cost)}" : ""
531
+ compact_note = has_compaction ? " \u00b7 \u21ba compaction" : ""
532
+ base = num ? "Thread #{num}" : "Prompt #{i + 1}"
533
+ tip_text = escape_html(escape_js("#{base} \u00b7 #{tok_str} tokens#{cost_str}#{compact_note}"))
534
+ tip_prompt = escape_html(escape_js(primary[:token].human_text))
535
+ prompt_search = escape_html(truncate(primary[:token].human_text, 300).downcase)
536
+
537
+ badge = has_compaction ? %(<span class="hm-compact-badge">\u21ba</span>) : ""
538
+ %(<div class="hm-cell" tabindex="0" data-idx="#{i}" data-cost="#{combined_cost}" data-tokens="#{combined_tokens}" data-prompt="#{prompt_search}" data-color-cost="#{color_cost}" data-color-token="#{color_token}" data-ox="#{ox}" data-ow="#{ow}" data-cx="#{cx}" data-cw="#{cw}" data-tip="#{tip_text}" data-tip-prompt="#{tip_prompt}" style="background-color:#{color_token}" onmouseover="hmTip(this)" onmouseout="tip('')" onclick="openPrompt(#{i})"><span class="hm-idx">#{i + 1}</span>#{badge}</div>)
539
+ }.join("\n")
540
+
541
+ <<~HTML
542
+ <div id="heatmap" class="heatmap">
543
+ <div class="hm-meta">
544
+ <span class="hm-hint">Hover to preview &middot; Click to open flame graph</span>
545
+ </div>
546
+ <div class="hm-controls">
547
+ <span class="hm-section-label">Prompts</span>
548
+ <input type="text" id="hm-search" class="hm-search" placeholder="Search prompts\u2026" oninput="filterHeatmap(this.value)">
549
+ <button class="hm-sort-btn" id="hm-sort-btn" onclick="sortHeatmap()">&#x21C5; Sort by tokens</button>
550
+ <div class="hm-legend" id="hm-legend">
551
+ <span class="hm-legend-label" id="hm-legend-lo">few tokens</span>
552
+ <span class="hm-legend-ramp" id="hm-legend-ramp" data-token-grad="linear-gradient(to right, #073030, #00CED1)" data-cost-grad="linear-gradient(to right, #200510, #FF1493)" style="background:linear-gradient(to right, #073030, #00CED1)"></span>
553
+ <span class="hm-legend-label" id="hm-legend-hi">many</span>
554
+ </div>
555
+ </div>
556
+ <div class="hm-grid" id="hm-grid">
557
+ #{cells}
558
+ </div>
559
+ <div class="hm-empty" id="hm-empty">Click a prompt cell to explore its flame graph</div>
560
+ </div>
561
+ HTML
562
+ end
563
+
435
564
  def css
436
565
  <<~CSS
437
566
  :root {
@@ -440,6 +569,7 @@ module TokenLens
440
569
  --bar-assistant: #00CED1;
441
570
  --bar-tool: #00A8E8;
442
571
  --bar-sidechain: #C97BFF;
572
+ --bar-compaction: #607B8B;
443
573
  --bar-user: #1a1a2e;
444
574
  --bg: #000000;
445
575
  --surface: #0a0a14;
@@ -466,7 +596,7 @@ module TokenLens
466
596
  }
467
597
  * { box-sizing: border-box; margin: 0; padding: 0; }
468
598
  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); }
599
+ 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; height: 100%; color: var(--text); }
470
600
  .header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; border-bottom: 1px solid var(--border); }
471
601
  .summary { color: var(--text-dim); font-size: 11px; line-height: 20px; }
472
602
  .header-btns { display: flex; gap: 6px; align-items: center; }
@@ -477,33 +607,67 @@ module TokenLens
477
607
  .reset-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent); }
478
608
  .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
609
  .theme-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent2); }
480
- .spacer { flex: 1; }
481
- .flame { position: relative; width: 100%; }
610
+ .flame-wrap { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; overflow-x: auto; overflow-y: hidden; width: 100%; }
611
+ .flame { position: relative; overflow: hidden; flex-shrink: 0; }
612
+ .heatmap { flex-shrink: 0; padding: 12px 8px 8px; background: var(--bg); border-bottom: 1px solid var(--border); }
613
+ .hm-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
614
+ .hm-controls { display: flex; gap: 8px; align-items: center; justify-content: center; margin-bottom: 8px; }
615
+ .hm-search { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 3px; padding: 3px 8px; font-size: 11px; font-family: inherit; outline: none; width: 220px; }
616
+ .hm-search:focus { border-color: var(--accent2); }
617
+ .hm-sort-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; }
618
+ .hm-sort-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent2); }
619
+ .hm-sort-btn.active { border-color: var(--accent2); color: var(--accent2); }
620
+ .hm-grid { display: flex; flex-wrap: wrap; gap: 3px; justify-content: center; }
621
+ .hm-cell { width: 32px; height: 32px; border-radius: 3px; cursor: pointer; transition: opacity 0.1s ease-out, transform 0.1s ease-out, box-shadow 0.1s ease-out; flex-shrink: 0; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10); display: flex; align-items: center; justify-content: center; position: relative; }
622
+ .hm-cell:hover { transform: scale(1.08); z-index: 10; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10), 0 0 8px var(--accent-faint); }
623
+ .hm-cell:focus-visible { outline: 2px solid var(--accent2); outline-offset: 1px; z-index: 10; }
624
+ .hm-cell.hm-active { box-shadow: 0 0 0 2px var(--accent); transform: scale(1.15); z-index: 11; }
625
+ .hm-idx { font-size: 9px; font-weight: 700; color: rgba(255,255,255,0.6); pointer-events: none; line-height: 1; user-select: none; }
626
+ .hm-compact-badge { position: absolute; top: 2px; right: 3px; font-size: 8px; color: rgba(255,255,255,0.75); pointer-events: none; line-height: 1; }
627
+ .hm-cell.hm-dimmed { opacity: 0.15; }
628
+ .heatmap.hm-strip { padding: 4px 8px; border-bottom: 1px solid var(--border); }
629
+ .heatmap.hm-strip .hm-meta { display: none; }
630
+ .heatmap.hm-strip .hm-controls { display: none; }
631
+ .heatmap.hm-strip .hm-grid { gap: 2px; }
632
+ .heatmap.hm-strip .hm-cell { width: 16px; height: 16px; border-radius: 2px; }
633
+ .hm-back { padding: 4px 12px; font-size: 11px; color: var(--accent); cursor: pointer; border-bottom: 1px solid var(--border); background: var(--surface); user-select: none; }
634
+ .hm-back:hover { background: var(--border); }
635
+ .heatmap.hm-strip .hm-idx { display: none; }
636
+ .hm-section-label { color: var(--text-dim); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; }
637
+ .hm-hint { color: var(--text-dim); font-size: 10px; opacity: 0.6; white-space: nowrap; }
638
+ .hm-legend { display: flex; align-items: center; gap: 4px; }
639
+ .hm-legend-label { color: var(--text-dim); font-size: 9px; opacity: 0.7; white-space: nowrap; }
640
+ .hm-legend-ramp { width: 64px; height: 8px; border-radius: 2px; flex-shrink: 0; }
641
+ .hm-empty { color: var(--text-dim); font-size: 12px; text-align: center; padding: 40px 0 20px; opacity: 0.5; }
642
+ .heatmap.hm-strip .hm-empty { display: none; }
482
643
  .bar {
483
644
  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);
645
+ height: #{ROW_HEIGHT}px;
646
+ border-radius: 0;
647
+ border-right: 1.5px solid rgba(0,0,0,0.5);
648
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.10), inset 0 -1px 0 rgba(0,0,0,0.25);
488
649
  cursor: pointer;
489
650
  overflow: hidden;
490
651
  user-select: none;
652
+ transition: left 0.2s ease, width 0.2s ease;
491
653
  }
492
- .bar:hover { filter: brightness(1.15) saturate(1.1); }
654
+ .bar:hover { filter: brightness(1.18) saturate(1.1); }
493
655
  .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); }
656
+ .bar-compaction { background: linear-gradient(to right, #7a96a8 0%, #607B8B 12%, #607B8B 88%, #4a6070 100%) !important; box-shadow: inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -1px 0 rgba(0,0,0,0.3); }
495
657
  .bar-pressure { box-shadow: 0 0 5px 1px rgba(255,20,147,0.4); }
658
+ .bar-alt { filter: brightness(0.82); }
496
659
  .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); }
660
+ .bar-c-human { background: linear-gradient(to right, #ff3faa 0%, #FF1493 12%, #FF1493 88%, #c8107a 100%); }
661
+ .bar-c-task { background: linear-gradient(to right, #ffc84e 0%, #FFB020 12%, #FFB020 88%, #d9941a 100%); }
662
+ .bar-c-assistant{ background: linear-gradient(to right, #1ee8eb 0%, #00CED1 12%, #00CED1 88%, #00abb0 100%); }
663
+ .bar-c-tool { background: linear-gradient(to right, #22c4ff 0%, #00A8E8 12%, #00A8E8 88%, #0088c0 100%); }
664
+ .bar-c-sidechain{ background: linear-gradient(to right, #d88fff 0%, #C97BFF 12%, #C97BFF 88%, #a85ee0 100%); }
665
+ .bar-c-user { background: var(--bar-user); }
666
+ .bar-c-sidechain .lbl, .bar-c-tool .lbl { color: rgba(255,255,255,0.9); }
503
667
  .lbl {
504
668
  display: block;
505
669
  padding: 0 5px;
506
- line-height: #{ROW_HEIGHT - 4}px;
670
+ line-height: #{ROW_HEIGHT}px;
507
671
  overflow: hidden;
508
672
  text-overflow: ellipsis;
509
673
  white-space: nowrap;
@@ -525,10 +689,13 @@ module TokenLens
525
689
  .legend { padding: 2px 8px 6px; display: flex; gap: 14px; flex-wrap: wrap; }
526
690
  .legend-item { display: flex; align-items: center; gap: 5px; color: var(--text-dim); font-size: 10px; }
527
691
  .legend-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 1px; flex-shrink: 0; }
692
+ .legend-swatch.bar-compaction { background: var(--bar-compaction) !important; }
528
693
  .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
694
  .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
695
  .summary-btn:hover { background: var(--surface); color: var(--text); border-color: var(--accent); }
531
696
  .summary-btn.active { border-color: var(--accent); color: var(--accent); }
697
+ .export-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; }
698
+ .export-btn:hover { background: var(--surface); color: var(--accent2); border-color: var(--accent2); }
532
699
  .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
700
  .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
701
  .summary-close { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 0 2px; line-height: 1; font-family: inherit; }
@@ -541,12 +708,13 @@ module TokenLens
541
708
  end
542
709
 
543
710
  def js
544
- w = COORD_WIDTH
545
- min_pct = MIN_LABEL_PCT
711
+ w = @canvas_width
712
+ min_lbl_px = MIN_LABEL_PX
546
713
  <<~JS
547
714
  (function() {
548
- var W = #{w}, MIN_PCT = #{min_pct};
549
- var costMode = false;
715
+ var W = #{w}, MIN_LBL_PX = #{min_lbl_px};
716
+ var costMode = false, zoomedEl = null;
717
+ var hmActiveIdx = -1, hmSorted = false, hmCount = #{@hm_count};
550
718
  function bars() { return Array.from(document.querySelectorAll('.bar:not(.total-bar)')); }
551
719
  function applyBar(el, nx, nw) {
552
720
  if (nx + nw <= 0 || nx >= W) {
@@ -554,12 +722,19 @@ module TokenLens
554
722
  } else {
555
723
  el.style.display = '';
556
724
  el.style.left = (nx / W * 100) + '%';
557
- el.style.width = Math.max(nw, 1) / W * 100 + '%';
725
+ el.style.width = 'calc(' + (Math.max(nw, 1) / W * 100) + '% + 1px)';
558
726
  var lbl = el.querySelector('.lbl');
559
- if (lbl) lbl.style.display = (nw / W * 100 < MIN_PCT) ? 'none' : '';
727
+ if (lbl) lbl.style.display = (nw < MIN_LBL_PX) ? 'none' : '';
560
728
  }
561
729
  }
562
730
  function resetBtn() { return document.getElementById('reset-btn'); }
731
+ function withoutTransition(fn) {
732
+ document.querySelectorAll('.bar').forEach(function(b) { b.style.transition = 'none'; });
733
+ fn();
734
+ var first = document.querySelector('.bar');
735
+ if (first) first.offsetHeight;
736
+ document.querySelectorAll('.bar').forEach(function(b) { b.style.transition = ''; });
737
+ }
563
738
  window.toggleTheme = function() {
564
739
  var root = document.documentElement;
565
740
  var isLight = root.getAttribute('data-theme') === 'light';
@@ -576,29 +751,52 @@ module TokenLens
576
751
  if (btn) btn.classList.toggle('active', !shown);
577
752
  };
578
753
  window.toggleCostView = function() {
754
+ var wasZoomed = zoomedEl;
755
+ if (wasZoomed) unzoom();
579
756
  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
- }
757
+ withoutTransition(function() {
758
+ bars().forEach(function(b) {
759
+ var nx = costMode ? +b.getAttribute('data-cx') : +b.getAttribute('data-ox');
760
+ var nw = costMode ? +b.getAttribute('data-cw') : +b.getAttribute('data-ow');
761
+ applyBar(b, nx, nw);
762
+ var lbl = b.querySelector('.lbl');
763
+ if (lbl) {
764
+ var text = b.getAttribute(costMode ? 'data-cost-lbl' : 'data-token-lbl');
765
+ if (text !== null) lbl.textContent = text;
766
+ }
767
+ });
591
768
  });
592
769
  var tl = document.getElementById('total-lbl');
593
770
  if (tl) tl.innerHTML = tl.getAttribute(costMode ? 'data-cost-text' : 'data-token-text');
594
771
  var cb = document.getElementById('cost-btn');
595
772
  if (cb) { cb.textContent = costMode ? '# Token view' : '$ Cost view'; cb.classList.toggle('active', costMode); }
596
- var rb = resetBtn(); if (rb) rb.style.display = 'none';
773
+ hmCells().forEach(function(c) { c.style.backgroundColor = c.getAttribute(costMode ? 'data-color-cost' : 'data-color-token'); });
774
+ var sb = document.getElementById('hm-sort-btn');
775
+ if (sb && hmSorted) sb.innerHTML = costMode ? '&#x21C5; Sort by cost' : '&#x21C5; Sort by tokens';
776
+ var ramp = document.getElementById('hm-legend-ramp');
777
+ if (ramp) ramp.style.background = costMode ? ramp.getAttribute('data-cost-grad') : ramp.getAttribute('data-token-grad');
778
+ var lmLo = document.getElementById('hm-legend-lo');
779
+ var lmHi = document.getElementById('hm-legend-hi');
780
+ if (lmLo) lmLo.textContent = costMode ? 'cheap' : 'few tokens';
781
+ if (lmHi) lmHi.textContent = costMode ? 'costly' : 'many';
782
+ scaleHeatmap();
783
+ if (wasZoomed) {
784
+ if (wasZoomed.classList && wasZoomed.classList.contains('hm-cell')) {
785
+ openPrompt(+wasZoomed.getAttribute('data-idx'));
786
+ } else {
787
+ zoom(wasZoomed);
788
+ }
789
+ }
597
790
  };
598
791
  window.zoom = function(el) {
599
792
  var fx = costMode ? +el.getAttribute('data-cx') : +el.getAttribute('data-ox');
600
793
  var fw = costMode ? +el.getAttribute('data-cw') : +el.getAttribute('data-ow');
601
794
  if (fw >= W - 1) { unzoom(); return; }
795
+ var wrap = document.getElementById('flame-wrap');
796
+ var flame = document.querySelector('.flame');
797
+ if (!flame.getAttribute('data-orig-w')) flame.setAttribute('data-orig-w', flame.style.width);
798
+ flame.style.width = wrap.clientWidth + 'px';
799
+ wrap.scrollLeft = 0;
602
800
  bars().forEach(function(b) {
603
801
  if (!b.getAttribute('ox')) {
604
802
  b.setAttribute('ox', costMode ? +b.getAttribute('data-cx') : +b.getAttribute('data-ox'));
@@ -607,8 +805,12 @@ module TokenLens
607
805
  applyBar(b, (+b.getAttribute('ox') - fx) / fw * W, +b.getAttribute('ow') / fw * W);
608
806
  });
609
807
  var btn = resetBtn(); if (btn) btn.style.display = 'inline-block';
808
+ zoomedEl = el;
610
809
  };
611
810
  window.unzoom = function() {
811
+ var flame = document.querySelector('.flame');
812
+ var origW = flame.getAttribute('data-orig-w');
813
+ if (origW) { flame.style.width = origW; flame.removeAttribute('data-orig-w'); }
612
814
  bars().forEach(function(b) {
613
815
  var ox = b.getAttribute('ox');
614
816
  if (ox) {
@@ -618,12 +820,65 @@ module TokenLens
618
820
  }
619
821
  });
620
822
  var btn = resetBtn(); if (btn) btn.style.display = 'none';
823
+ zoomedEl = null;
824
+ };
825
+ // resetZoom: if sub-zoomed inside a prompt → unzoom back to prompt view;
826
+ // if viewing a prompt (or full overview) → close back to heatmap
827
+ window.resetZoom = function() {
828
+ if (zoomedEl && zoomedEl.classList && !zoomedEl.classList.contains('hm-cell')) {
829
+ unzoom();
830
+ } else {
831
+ closePrompt();
832
+ }
833
+ };
834
+ function escSVG(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
835
+ window.exportSVG = function() {
836
+ var SVG_W = 1600, HDR = 28, MIN_H = 400;
837
+ var flame = document.querySelector('.flame');
838
+ var flameH = parseFloat(flame.style.height) || flame.offsetHeight;
839
+ var svgH = Math.max(MIN_H, Math.round(flameH) + HDR);
840
+ var yOffset = svgH - Math.round(flameH) - HDR; // push bars to bottom
841
+ var allBars = Array.from(document.querySelectorAll('.flame .bar')).filter(function(b) { return b.style.display !== 'none'; });
842
+ if (!allBars.length) return;
843
+ var st = getComputedStyle(document.documentElement);
844
+ var bg = st.getPropertyValue('--bg').trim() || '#000';
845
+ var surface = st.getPropertyValue('--surface').trim() || '#0a0a14';
846
+ var border = st.getPropertyValue('--border').trim() || '#1a1a2e';
847
+ var textDim = st.getPropertyValue('--text-dim').trim() || '#8892a0';
848
+ var barText = st.getPropertyValue('--bar-text').trim() || '#000';
849
+ var summaryEl = document.querySelector('.summary');
850
+ var summaryStr = summaryEl ? summaryEl.textContent.trim() : '';
851
+ var defs = [], rects = [], texts = [];
852
+ rects.push('<rect x="0" y="0" width="' + SVG_W + '" height="' + HDR + '" fill="' + surface + '"/>');
853
+ rects.push('<line x1="0" y1="' + HDR + '" x2="' + SVG_W + '" y2="' + HDR + '" stroke="' + border + '" stroke-width="1"/>');
854
+ texts.push('<text x="8" y="18" font-family="monospace" font-size="11" fill="' + textDim + '">' + escSVG(summaryStr) + '</text>');
855
+ allBars.forEach(function(bar, i) {
856
+ var x = Math.round(parseFloat(bar.style.left) / 100 * SVG_W);
857
+ var w = Math.max(1, Math.round(parseFloat(bar.style.width) / 100 * SVG_W));
858
+ var y = Math.round(parseFloat(bar.style.top)) + HDR + yOffset;
859
+ var h = Math.round(parseFloat(getComputedStyle(bar).height));
860
+ if (isNaN(x) || isNaN(y) || isNaN(w) || h <= 0) return;
861
+ var fill = getComputedStyle(bar).backgroundColor;
862
+ var cid = 'c' + i;
863
+ defs.push('<clipPath id="' + cid + '"><rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '"/></clipPath>');
864
+ rects.push('<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" fill="' + fill + '"/>');
865
+ var lbl = bar.querySelector('.lbl');
866
+ if (lbl && lbl.style.display !== 'none' && w > 28 && lbl.textContent.trim()) {
867
+ var isTotal = bar.classList.contains('total-bar');
868
+ texts.push('<text x="' + (x+3) + '" y="' + (y + h/2 + 4) + '" font-family="monospace" font-size="11" fill="' + (isTotal ? textDim : barText) + '" font-weight="' + (isTotal ? '600' : '400') + '" clip-path="url(#' + cid + ')">' + escSVG(lbl.textContent) + '</text>');
869
+ }
870
+ });
871
+ var svg = '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="' + SVG_W + '" height="' + svgH + '"><rect width="100%" height="100%" fill="' + bg + '"/><defs>' + defs.join('') + '</defs>' + rects.join('') + texts.join('') + '</svg>';
872
+ var a = document.createElement('a');
873
+ a.href = URL.createObjectURL(new Blob([svg], {type: 'image/svg+xml'}));
874
+ a.download = 'token-lens.svg';
875
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
621
876
  };
622
877
  function esc(t) { return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
623
878
  window.tip = function(s, prompt) {
624
879
  var el = document.getElementById('tip');
625
880
  if (!el) return;
626
- if (!s && !prompt) { el.innerHTML = '&nbsp;'; return; }
881
+ if (!s && !prompt) { el.innerHTML = '<span class="tip-label">Hover for details \u00b7 Click to zoom</span>'; return; }
627
882
  var sep = '<span class="tip-sep">\u00b7</span>';
628
883
  var parts = s ? s.split(' | ').filter(Boolean) : [];
629
884
  var html = parts.map(function(p, i) {
@@ -634,7 +889,7 @@ module TokenLens
634
889
  if (/^[^:]*:\s/.test(p)) return '<span class="tip-label">' + e + '</span>';
635
890
  return '<span class="tip-code">' + e + '</span>';
636
891
  }).join(sep);
637
- if (prompt) html += '<span class="tip-prompt">' + esc(prompt) + '</span>';
892
+ if (prompt) html += (html ? sep : '') + '<span class="tip-prompt">' + esc(prompt) + '</span>';
638
893
  el.innerHTML = html;
639
894
  };
640
895
  var mx = 0, my = 0;
@@ -659,6 +914,134 @@ module TokenLens
659
914
  var bar = e.target.closest && e.target.closest('.bar:not(.total-bar)');
660
915
  if (bar) { var ft = document.getElementById('ftip'); if (ft) ft.style.display = 'none'; }
661
916
  });
917
+ // Disable transitions on initial load
918
+ document.querySelectorAll('.bar').forEach(function(b) { b.style.transition = 'none'; });
919
+ requestAnimationFrame(function() { requestAnimationFrame(function() {
920
+ document.querySelectorAll('.bar').forEach(function(b) { b.style.transition = ''; });
921
+ }); });
922
+ // Heatmap
923
+ function hmCells() { return Array.from(document.querySelectorAll('.hm-cell')); }
924
+ window.hmTip = function(el) { tip(el.getAttribute('data-tip'), el.getAttribute('data-tip-prompt')); };
925
+ function zoomToRoot(cell) {
926
+ var fx = costMode ? +cell.getAttribute('data-cx') : +cell.getAttribute('data-ox');
927
+ var fw = costMode ? +cell.getAttribute('data-cw') : +cell.getAttribute('data-ow');
928
+ if (fw <= 0) return;
929
+ var wrap = document.getElementById('flame-wrap');
930
+ var flame = document.querySelector('.flame');
931
+ if (!flame.getAttribute('data-orig-w')) flame.setAttribute('data-orig-w', flame.style.width);
932
+ flame.style.width = wrap.clientWidth + 'px';
933
+ wrap.scrollLeft = 0;
934
+ bars().forEach(function(b) {
935
+ if (!b.getAttribute('ox')) {
936
+ b.setAttribute('ox', costMode ? +b.getAttribute('data-cx') : +b.getAttribute('data-ox'));
937
+ b.setAttribute('ow', costMode ? +b.getAttribute('data-cw') : +b.getAttribute('data-ow'));
938
+ }
939
+ applyBar(b, (+b.getAttribute('ox') - fx) / fw * W, +b.getAttribute('ow') / fw * W);
940
+ });
941
+ zoomedEl = cell;
942
+ }
943
+ window.openPrompt = function(idx) {
944
+ hmActiveIdx = idx;
945
+ var hm = document.getElementById('heatmap');
946
+ if (hm) hm.classList.add('hm-strip');
947
+ hmCells().forEach(function(c) { c.classList.toggle('hm-active', +c.getAttribute('data-idx') === idx); });
948
+ var fw = document.getElementById('flame-wrap');
949
+ if (fw) fw.style.display = '';
950
+ var lg = document.getElementById('legend'); if (lg) lg.style.display = '';
951
+ var back = document.getElementById('hm-back');
952
+ if (back) back.style.display = '';
953
+ var cell = document.querySelector('.hm-cell[data-idx="' + idx + '"]');
954
+ if (cell) { withoutTransition(function() { zoomToRoot(cell); }); }
955
+ var btn = resetBtn(); if (btn) btn.style.display = 'none';
956
+ };
957
+ window.closePrompt = function() {
958
+ hmActiveIdx = -1;
959
+ unzoom();
960
+ var fw = document.getElementById('flame-wrap');
961
+ if (fw) fw.style.display = 'none';
962
+ var lg = document.getElementById('legend'); if (lg) lg.style.display = 'none';
963
+ var hm = document.getElementById('heatmap');
964
+ if (hm) hm.classList.remove('hm-strip');
965
+ hmCells().forEach(function(c) { c.classList.remove('hm-active'); });
966
+ var back = document.getElementById('hm-back');
967
+ if (back) back.style.display = 'none';
968
+ var si = document.getElementById('hm-search');
969
+ if (si) { si.value = ''; filterHeatmap(''); }
970
+ };
971
+ window.sortHeatmap = function() {
972
+ hmSorted = !hmSorted;
973
+ var grid = document.getElementById('hm-grid');
974
+ if (!grid) return;
975
+ var cells = hmCells();
976
+ if (hmSorted) {
977
+ cells.sort(function(a, b) {
978
+ var av = costMode ? parseFloat(a.getAttribute('data-cost')) : parseInt(a.getAttribute('data-tokens'));
979
+ var bv = costMode ? parseFloat(b.getAttribute('data-cost')) : parseInt(b.getAttribute('data-tokens'));
980
+ return bv - av;
981
+ });
982
+ } else {
983
+ cells.sort(function(a, b) { return parseInt(a.getAttribute('data-idx')) - parseInt(b.getAttribute('data-idx')); });
984
+ }
985
+ cells.forEach(function(c) { grid.appendChild(c); });
986
+ scaleHeatmap();
987
+ var btn = document.getElementById('hm-sort-btn');
988
+ if (btn) btn.classList.toggle('active', hmSorted);
989
+ };
990
+ window.filterHeatmap = function(query) {
991
+ var q = query.toLowerCase().trim();
992
+ hmCells().forEach(function(c) {
993
+ var match = !q || (c.getAttribute('data-prompt') || '').indexOf(q) !== -1;
994
+ c.classList.toggle('hm-dimmed', !match);
995
+ });
996
+ scaleHeatmap();
997
+ };
998
+ function scaleHeatmap() {
999
+ var hm = document.getElementById('heatmap');
1000
+ if (hm && hm.classList.contains('hm-strip')) return;
1001
+ var allCells = hmCells();
1002
+ var cells = allCells.filter(function(c) { return !c.classList.contains('hm-dimmed'); });
1003
+ var n = cells.length || 1;
1004
+ var maxCell = Math.min(96, Math.max(36, Math.floor(1400 / Math.sqrt(n))));
1005
+ var minCell = 16;
1006
+ var vals = cells.map(function(c) {
1007
+ return costMode ? parseFloat(c.getAttribute('data-cost')) : parseInt(c.getAttribute('data-tokens'));
1008
+ });
1009
+ var maxVal = Math.max.apply(null, vals) || 1;
1010
+ var minVal = Math.min.apply(null, vals) || 0;
1011
+ cells.forEach(function(c, i) {
1012
+ var v = vals[i];
1013
+ var t = (maxVal === minVal) ? 0.5 : (v - minVal) / (maxVal - minVal);
1014
+ var sz = Math.round(minCell + (maxCell - minCell) * Math.sqrt(t));
1015
+ c.style.width = sz + 'px';
1016
+ c.style.height = sz + 'px';
1017
+ var lbl = c.querySelector('.hm-idx');
1018
+ if (lbl) lbl.style.display = (sz < 24) ? 'none' : '';
1019
+ });
1020
+ allCells.filter(function(c) { return c.classList.contains('hm-dimmed'); }).forEach(function(c) {
1021
+ c.style.width = minCell + 'px';
1022
+ c.style.height = minCell + 'px';
1023
+ });
1024
+ }
1025
+ document.addEventListener('keydown', function(e) {
1026
+ if (e.key === 'Enter' && document.activeElement && document.activeElement.classList.contains('hm-cell')) {
1027
+ e.preventDefault();
1028
+ openPrompt(+document.activeElement.getAttribute('data-idx'));
1029
+ return;
1030
+ }
1031
+ if (hmActiveIdx < 0) return;
1032
+ if (e.target && e.target.tagName === 'INPUT') return;
1033
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
1034
+ e.preventDefault();
1035
+ if (hmActiveIdx > 0) openPrompt(hmActiveIdx - 1);
1036
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
1037
+ e.preventDefault();
1038
+ if (hmActiveIdx < hmCount - 1) openPrompt(hmActiveIdx + 1);
1039
+ } else if (e.key === 'Escape') {
1040
+ e.preventDefault();
1041
+ closePrompt();
1042
+ }
1043
+ });
1044
+ scaleHeatmap();
662
1045
  })();
663
1046
  JS
664
1047
  end
@@ -5,6 +5,7 @@ module TokenLens
5
5
  class Layout
6
6
  CANVAS_WIDTH = 1200
7
7
  ROW_HEIGHT = 32
8
+ MIN_THREAD_WIDTH = 80 # minimum pixels per root thread so slivers are readable
8
9
 
9
10
  def initialize(canvas_width: CANVAS_WIDTH)
10
11
  @canvas_width = canvas_width
@@ -12,15 +13,17 @@ module TokenLens
12
13
 
13
14
  def layout(nodes)
14
15
  max_depth = all_nodes(nodes).map { |n| n[:depth] }.max || 0
16
+ effective_width = [@canvas_width, nodes.length * MIN_THREAD_WIDTH].max
17
+
15
18
  total = nodes.sum { |n| n[:subtree_tokens] }
16
- scale = (total > 0) ? @canvas_width.to_f / total : 1.0
19
+ scale = (total > 0) ? effective_width.to_f / total : 1.0
17
20
  position(nodes, x: 0, scale: scale, max_depth: max_depth)
18
21
 
19
22
  total_cost = nodes.sum { |n| n[:subtree_cost] }
20
- cost_scale = (total_cost > 0) ? @canvas_width.to_f / total_cost : 1.0
23
+ cost_scale = (total_cost > 0) ? effective_width.to_f / total_cost : 1.0
21
24
  position_cost(nodes, x: 0, scale: cost_scale, max_depth: max_depth)
22
25
 
23
- nodes
26
+ effective_width
24
27
  end
25
28
 
26
29
  private
@@ -28,23 +31,25 @@ module TokenLens
28
31
  # Bottom-up layout: roots at bottom (y = max_depth * ROW_HEIGHT),
29
32
  # deepest children at top (y = 0).
30
33
  def position(nodes, x:, scale:, max_depth:)
31
- cursor = x
34
+ cursor = x.to_f
32
35
  nodes.each do |node|
33
- node[:x] = cursor
36
+ start = cursor.round
37
+ cursor += node[:subtree_tokens] * scale
38
+ node[:x] = start
34
39
  node[:y] = (max_depth - node[:depth]) * ROW_HEIGHT
35
- node[:w] = (node[:subtree_tokens] * scale).round
36
- position(node[:children], x: cursor, scale: scale, max_depth: max_depth)
37
- cursor += node[:w]
40
+ node[:w] = cursor.round - start
41
+ position(node[:children], x: node[:x], scale: scale, max_depth: max_depth)
38
42
  end
39
43
  end
40
44
 
41
45
  def position_cost(nodes, x:, scale:, max_depth:)
42
- cursor = x
46
+ cursor = x.to_f
43
47
  nodes.each do |node|
44
- node[:cost_x] = cursor
45
- node[:cost_w] = (node[:subtree_cost] * scale).round
46
- position_cost(node[:children], x: cursor, scale: scale, max_depth: max_depth)
47
- cursor += node[:cost_w]
48
+ start = cursor.round
49
+ cursor += node[:subtree_cost] * scale
50
+ node[:cost_x] = start
51
+ node[:cost_w] = cursor.round - start
52
+ position_cost(node[:children], x: node[:cost_x], scale: scale, max_depth: max_depth)
48
53
  end
49
54
  end
50
55
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TokenLens
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: token-lens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rob durst
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-23 00:00:00.000000000 Z
10
+ date: 2026-03-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: thor