claude-matrix 1.0.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,111 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module ClaudeMatrix
5
+ module Readers
6
+ class SessionParser
7
+ PROJECTS_PATH = File.expand_path("~/.claude/projects")
8
+
9
+ # Returns array of parsed session hashes
10
+ def self.parse_all(&progress_block)
11
+ sessions = []
12
+ all_files = jsonl_files
13
+
14
+ all_files.each_with_index do |file, i|
15
+ progress_block&.call(i + 1, all_files.size)
16
+ session = parse_file(file)
17
+ sessions << session if session
18
+ end
19
+
20
+ sessions
21
+ end
22
+
23
+ def self.jsonl_files
24
+ return [] unless Dir.exist?(PROJECTS_PATH)
25
+ Dir.glob("#{PROJECTS_PATH}/**/*.jsonl").sort
26
+ end
27
+
28
+ def self.project_name_from_path(file_path)
29
+ # ~/.claude/projects/-Users-foo-Work-circle/session.jsonl
30
+ # => "circle"
31
+ parts = file_path.split("/")
32
+ encoded = parts[-2]
33
+ decoded = encoded.gsub("-", "/").sub(%r{^/}, "")
34
+ decoded.split("/").last || encoded
35
+ end
36
+
37
+ def self.parse_file(file_path)
38
+ lines = File.readlines(file_path, chomp: true).reject(&:empty?)
39
+ return nil if lines.empty?
40
+
41
+ tools = []
42
+ timestamps = []
43
+ input_tokens = 0
44
+ output_tokens = 0
45
+ cache_read = 0
46
+ cache_create = 0
47
+ model = nil
48
+ git_branch = nil
49
+ session_id = nil
50
+ cwd = nil
51
+
52
+ lines.each do |line|
53
+ data = JSON.parse(line)
54
+
55
+ session_id ||= data["sessionId"]
56
+ git_branch ||= data["gitBranch"]
57
+ cwd ||= data["cwd"]
58
+
59
+ ts = data["timestamp"]
60
+ timestamps << Time.parse(ts) if ts
61
+
62
+ if data["type"] == "assistant"
63
+ msg = data["message"] || {}
64
+ model ||= msg["model"]
65
+
66
+ # token usage
67
+ usage = data["usage"] || msg["usage"] || {}
68
+ input_tokens += (usage["input_tokens"] || 0)
69
+ output_tokens += (usage["output_tokens"] || 0)
70
+ cache_read += (usage["cache_read_input_tokens"] || 0)
71
+ cache_create += (usage["cache_creation_input_tokens"] || 0)
72
+
73
+ # tool calls
74
+ content = msg["content"] || []
75
+ content.each do |block|
76
+ tools << block["name"] if block["type"] == "tool_use" && block["name"]
77
+ end
78
+ end
79
+ rescue JSON::ParserError
80
+ next
81
+ end
82
+
83
+ return nil if timestamps.empty?
84
+
85
+ project_path = File.dirname(file_path)
86
+ file_name = File.basename(file_path, ".jsonl")
87
+
88
+ {
89
+ session_id: session_id || file_name,
90
+ file: file_path,
91
+ project: project_name_from_path(file_path),
92
+ project_path: cwd || project_path,
93
+ git_branch: git_branch,
94
+ started_at: timestamps.min,
95
+ ended_at: timestamps.max,
96
+ duration_secs: (timestamps.max - timestamps.min).to_i,
97
+ message_count: lines.count { |l| l.include?('"type":"user"') || l.include?('"type": "user"') },
98
+ tools: tools,
99
+ tool_count: tools.size,
100
+ model: model,
101
+ input_tokens: input_tokens,
102
+ output_tokens: output_tokens,
103
+ cache_read: cache_read,
104
+ cache_create: cache_create
105
+ }
106
+ rescue => e
107
+ nil
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,20 @@
1
+ require "json"
2
+
3
+ module ClaudeMatrix
4
+ module Readers
5
+ class StatsReader
6
+ STATS_PATH = File.expand_path("~/.claude/stats-cache.json")
7
+
8
+ def self.available?
9
+ File.exist?(STATS_PATH) && !File.empty?(STATS_PATH)
10
+ end
11
+
12
+ def self.read
13
+ return nil unless available?
14
+ JSON.parse(File.read(STATS_PATH))
15
+ rescue JSON::ParserError
16
+ nil
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module ClaudeMatrix
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,453 @@
1
+ require "tty-screen"
2
+ require "pastel"
3
+
4
+ module ClaudeMatrix
5
+ module Visualizers
6
+ class Dashboard
7
+ CONTENT = :content
8
+ DIVIDER = :divider
9
+
10
+ DAY_NAMES = %w[Mon Tue Wed Thu Fri Sat Sun].freeze
11
+ DAY_WDAY = [1, 2, 3, 4, 5, 6, 0].freeze
12
+
13
+ FILTER_LABELS = {
14
+ today: "Today",
15
+ week: "Week",
16
+ month: "Month",
17
+ all: "All Time"
18
+ }.freeze
19
+
20
+ def initialize(metrics, filter: :all)
21
+ @m = metrics
22
+ @filter = filter
23
+ @p = Pastel.new
24
+ @w = TTY::Screen.width
25
+ @h = TTY::Screen.height
26
+ # Content area: full height minus top(1) + header(1) + sep(1) + footer-sep(1) + footer(1) + bottom(1) = 6
27
+ @content_h = [@h - 6, 10].max
28
+ # Column widths
29
+ @lw = [(@w * 0.33).to_i, 28].max.clamp(28, 40)
30
+ @rw = @w - @lw - 3 # 3 = left│ + divider│ + right│
31
+ # Allocate right-column section heights
32
+ @n_tools, @act_h, @show_heatmap = allocate_right(@m[:top_tools].to_a.size)
33
+ end
34
+
35
+ # Returns the rendered string — exactly @h lines, no trailing newline
36
+ def render
37
+ left_rows = build_left
38
+ right_rows = build_right
39
+
40
+ left_rows.shift while left_rows.first&.dig(:type) == DIVIDER
41
+ right_rows.shift while right_rows.first&.dig(:type) == DIVIDER
42
+
43
+ left_rows = fit(left_rows, @content_h, @lw)
44
+ right_rows = fit(right_rows, @content_h, @rw)
45
+
46
+ out = []
47
+ out << top_border
48
+ out << header_line
49
+ out << dim("├" + "─" * @lw + "┬" + "─" * @rw + "┤")
50
+ @content_h.times { |i| out << merge_row(left_rows[i], right_rows[i]) }
51
+ out << dim("├" + "─" * (@w - 2) + "┤")
52
+ out << filter_bar
53
+ out << dim("└" + "─" * (@w - 2) + "┘")
54
+
55
+ # Join without trailing newline — prevents terminal scroll
56
+ print out.join("\n")
57
+ end
58
+
59
+ private
60
+
61
+ # ── Height allocation ──────────────────────────────────────────────────
62
+
63
+ def allocate_right(n_avail)
64
+ # Each right section costs: 1 divider + 1 title (except first section, divider stripped)
65
+ # Always show: Tools + Tokens(3 rows) + Activity
66
+ # Optionally: Heatmap (1 title + 8 content = 9)
67
+ tokens_rows = 3
68
+ heatmap_rows = 9 # title(1) + hour-header(1) + 7 days
69
+ # Fixed cost (sections×2 overhead, tokens content, minus 1 because first divider stripped)
70
+ fixed_3sec = 3 * 2 - 1 + tokens_rows # = 8
71
+ fixed_4sec = 4 * 2 - 1 + tokens_rows + heatmap_rows # = 19
72
+
73
+ pool3 = @content_h - fixed_3sec
74
+ pool4 = @content_h - fixed_4sec
75
+
76
+ if pool4 >= 5 # enough for tools(min 2) + activity(min 3)
77
+ n_tools = [n_avail, [(pool4 * 0.50).to_i, 2].max, 10].min
78
+ act_h = [pool4 - n_tools, 3].max
79
+ [n_tools, act_h, true]
80
+ else
81
+ n_tools = [n_avail, [(pool3 * 0.55).to_i, 2].max, 10].min
82
+ act_h = [pool3 - n_tools, 3].max
83
+ [n_tools, act_h, false]
84
+ end
85
+ end
86
+
87
+ # ── Borders ────────────────────────────────────────────────────────────
88
+
89
+ def top_border
90
+ filter_pill = " ◄ #{FILTER_LABELS[@filter]} ► "
91
+ title = " Claude Matrix "
92
+ ver = " v1.0 "
93
+ fill = [@w - 2 - vis(title) - vis(ver) - vis(filter_pill), 0].max
94
+ dim("┌") +
95
+ bold(bright_cyan(title)) +
96
+ dim("─" * (fill / 2)) +
97
+ bright_yellow(filter_pill) +
98
+ dim("─" * (fill - fill / 2)) +
99
+ dim(ver) +
100
+ dim("┐")
101
+ end
102
+
103
+ def header_line
104
+ s = @m[:streaks]
105
+ badge = s[:current] >= 7 ? " 🔥" : s[:current] >= 3 ? " ⚡" : ""
106
+ cost = @m[:estimated_cost]
107
+ cost_s = "$#{"%.2f" % cost}"
108
+
109
+ parts = [
110
+ "#{dim('Sessions')} #{bright_cyan(@m[:total_sessions].to_s)}",
111
+ "#{dim('Messages')} #{bright_cyan(fmt(@m[:total_messages]))}",
112
+ "#{dim('In')} #{green(fmt(@m[:total_input_tokens]))} #{dim('Out')} #{bright_yellow(fmt(@m[:total_output_tokens]))}",
113
+ "#{dim('Cost')} #{cost_color(cost, cost_s)}",
114
+ "#{dim('Streak')} #{streak_color(s[:current], "#{s[:current]}d#{badge}")}",
115
+ "#{dim('Since')} #{dim(@m[:first_session]&.strftime('%b %Y') || '—')}",
116
+ ]
117
+
118
+ # Build greedily — drop rightmost parts that don't fit
119
+ line = " "
120
+ parts.each do |p|
121
+ candidate = line == " " ? " #{p}" : "#{line} #{p}"
122
+ break if vis(candidate) > @w - 2
123
+ line = candidate
124
+ end
125
+ dim("│") + pad(line, @w - 2) + dim("│")
126
+ end
127
+
128
+ def filter_bar
129
+ tabs = FILTER_LABELS.map do |f, label|
130
+ key = f.to_s[0].upcase
131
+ if f == @filter
132
+ " " + bold(bright_white("[#{key}]#{label}")) + " "
133
+ else
134
+ " " + dim("[#{key}]") + dim(label) + " "
135
+ end
136
+ end.join(dim("│"))
137
+
138
+ right_keys = dim(" r") + dim(":refresh ") + dim("q") + dim(":quit ")
139
+ fill = [@w - 2 - vis(tabs) - vis(right_keys) - 2, 0].max
140
+ dim("│") + " " + tabs + " " * fill + right_keys + dim("│")
141
+ end
142
+
143
+ # ── Row renderer ──────────────────────────────────────────────────────
144
+
145
+ def merge_row(lr, rr)
146
+ ld = lr[:type] == DIVIDER
147
+ rd = rr[:type] == DIVIDER
148
+ if ld && rd then dim("├" + "─" * @lw + "┼" + "─" * @rw + "┤")
149
+ elsif ld then dim("├" + "─" * @lw + "┤") + rr[:text] + dim("│")
150
+ elsif rd then dim("│") + lr[:text] + dim("├" + "─" * @rw + "┤")
151
+ else dim("│") + lr[:text] + dim("│") + rr[:text] + dim("│")
152
+ end
153
+ end
154
+
155
+ # ── Left column ───────────────────────────────────────────────────────
156
+
157
+ def build_left
158
+ s = @m[:streaks]
159
+ ls = @m[:longest_session]
160
+ mpd = @m[:most_productive_day]
161
+ wm = @m[:work_modes]
162
+ bw = [@lw - 17, 1].max
163
+
164
+ rows = []
165
+ rows += section("STREAKS", @lw, [
166
+ kv("Current", streak_color(s[:current], "#{s[:current]} days") + (s[:current] >= 7 ? " 🔥" : s[:current] >= 3 ? " ⚡" : ""), @lw),
167
+ kv("Longest", bright_cyan("#{s[:longest]} days"), @lw),
168
+ kv("Active", dim("#{s[:active_days]} days"), @lw),
169
+ ])
170
+
171
+ rows += section("PERSONAL BESTS", @lw, [
172
+ kv("Longest", ls ? green(format_dur(ls[:duration_secs])) : dim("—"), @lw),
173
+ kv("Best Day", mpd ? bright_cyan("#{mpd[:date]}") + dim(" (#{mpd[:sessions]}s)") : dim("—"), @lw),
174
+ kv("Busiest", @m[:busiest_hour] ? bright_cyan("%02d:00" % @m[:busiest_hour]) : dim("—"), @lw),
175
+ kv("Top Tool", @m[:favorite_tool] ? green(@m[:favorite_tool][0,10]) + dim(" (#{@m[:tool_counts][@m[:favorite_tool]]})") : dim("—"), @lw),
176
+ ])
177
+
178
+ rows += section("WORK MODE", @lw, [
179
+ wbar("Explore", wm[:exploration], :cyan, bw),
180
+ wbar("Build", wm[:building], :green, bw),
181
+ wbar("Test", wm[:testing], :yellow, bw),
182
+ ])
183
+
184
+ name_w = [(@lw * 0.38).to_i, 8].max
185
+ time_w = 7
186
+ branch_w = [@lw - name_w - 2 - 4 - 2 - time_w - 2, 0].max
187
+
188
+ proj_lines = @m[:per_project].map do |proj, d|
189
+ last = d[:last_active] ? time_ago(d[:last_active]) : "—"
190
+ branch = d[:branches].first
191
+ binfo = branch_w > 2 && branch ? dim("[#{branch[0, branch_w]}]") : ""
192
+ " #{bright_cyan(proj[0, name_w].ljust(name_w))} #{dim(d[:sessions].to_s.rjust(2) + 's')} #{dim(last.ljust(time_w))} #{binfo}"
193
+ end
194
+ rows += section("PROJECTS", @lw, proj_lines)
195
+ rows
196
+ end
197
+
198
+ # ── Right column ──────────────────────────────────────────────────────
199
+
200
+ def build_right
201
+ rows = []
202
+ rows += section("TOP TOOLS", @rw, tool_lines)
203
+ rows += section("TOKENS & COST", @rw, token_lines)
204
+ rows += section("ACTIVITY (last 30 days)", @rw, activity_lines)
205
+ rows += section("HEATMAP day × hour", @rw, heatmap_lines) if @show_heatmap
206
+ rows
207
+ end
208
+
209
+ # ── Section helper ────────────────────────────────────────────────────
210
+
211
+ def section(title, w, lines)
212
+ [divr,
213
+ cr(sec_title(title, w), w)] + lines.map { |l| cr(l, w) }
214
+ end
215
+
216
+ def sec_title(title, w)
217
+ fill = [w - vis(title) - 2, 0].max
218
+ " #{bold(bright_cyan(title))} #{dim("─" * fill)}"
219
+ end
220
+
221
+ # ── Tool bars ─────────────────────────────────────────────────────────
222
+
223
+ def tool_lines
224
+ tools = @m[:top_tools].to_a
225
+ return [ind("No data yet")] if tools.empty?
226
+
227
+ max_cnt = tools.first.last.to_f
228
+ cnt_w = tools.first.last.to_s.length
229
+ bar_w = [@rw - 20 - cnt_w, 4].max
230
+
231
+ tools.first(@n_tools).map do |tool, count|
232
+ ratio = count.to_f / max_cnt
233
+ bw = [(ratio * bar_w).round, 1].max
234
+ # Color gradient: high→bright_cyan, mid→cyan, low→blue
235
+ bar_col = ratio > 0.7 ? method(:bright_cyan) : ratio > 0.35 ? method(:cyan) : method(:blue)
236
+ bar = bar_col.call("█" * bw) + dim("░" * [bar_w - bw, 0].max)
237
+ cnt_s = bright_white(count.to_s.rjust(cnt_w))
238
+ " #{dim(tool[0, 14].ljust(14))} #{bar} #{cnt_s}"
239
+ end
240
+ end
241
+
242
+ # ── Token / Cost bars ─────────────────────────────────────────────────
243
+
244
+ def token_lines
245
+ total_in = @m[:total_input_tokens]
246
+ total_out = @m[:total_output_tokens]
247
+ total_io = total_in + total_out
248
+ return [ind("No token data")] if total_io.zero?
249
+
250
+ bar_w = [@rw - 24, 4].max
251
+ cost = @m[:estimated_cost]
252
+
253
+ in_pct = (100.0 * total_in / total_io).round
254
+ out_pct = 100 - in_pct
255
+ in_bw = [(in_pct * bar_w / 100.0).round, 1].max
256
+ out_bw = [(out_pct * bar_w / 100.0).round, 1].max
257
+
258
+ in_bar = green("█" * in_bw) + dim("░" * [bar_w - in_bw, 0].max)
259
+ out_bar = bright_yellow("█" * out_bw) + dim("░" * [bar_w - out_bw, 0].max)
260
+
261
+ cache_eff = total_in > 0 ? (@m[:total_cache_read].to_f / total_in).round(1) : 0
262
+ cache_s = @m[:total_cache_read] > 0 ? " #{dim("cache")} #{bright_cyan(fmt(@m[:total_cache_read]))} #{dim("(#{cache_eff}× eff)")}" : ""
263
+
264
+ [
265
+ " #{green('In ')} #{green(fmt(total_in).rjust(7))} #{in_bar} #{dim("#{in_pct}%")}",
266
+ " #{bright_yellow('Out')} #{bright_yellow(fmt(total_out).rjust(7))} #{out_bar} #{dim("#{out_pct}%")}",
267
+ " #{dim('Est. Cost')} #{bold(cost_color(cost, "$#{"%.2f" % cost}"))}#{cache_s}",
268
+ ]
269
+ end
270
+
271
+ # ── Activity chart ────────────────────────────────────────────────────
272
+
273
+ def activity_lines
274
+ daily = @m[:daily_activity]
275
+ return [ind("No session data")] if daily.empty?
276
+
277
+ last30 = daily.to_a.last(30)
278
+ max_sess = [last30.map { |_, d| d[:sessions] }.max, 1].max.to_f
279
+ max_cols = [(@rw - 6) / 2, last30.size].min
280
+ cols = last30.last(max_cols)
281
+ n = cols.size
282
+ bar_h = [@act_h - 1, 2].max
283
+
284
+ grid = Array.new(bar_h) { Array.new(n, " ") }
285
+ cols.each_with_index do |(_, data), ci|
286
+ ratio = data[:sessions].to_f / max_sess
287
+ h = (ratio * bar_h).round
288
+ cell = ratio > 0.75 ? bright_green("██") :
289
+ ratio > 0.40 ? green("██") :
290
+ ratio > 0.0 ? cyan("▓▓") : " "
291
+ h.times { |r| grid[bar_h - 1 - r][ci] = cell }
292
+ end
293
+
294
+ peak = last30.map { |_, d| d[:sessions] }.max
295
+ lines = grid.each_with_index.map do |row, r|
296
+ suffix = r == 0 ? " #{dim(peak.to_s)}" : ""
297
+ " " + row.join + suffix
298
+ end
299
+
300
+ axis = Array.new(n * 2 + 10, " ")
301
+ [28, 21, 14, 7, 0].each do |ago|
302
+ ci = n - 1 - ago; next if ci < 0 || ci >= n
303
+ lbl = ago == 0 ? "today" : "#{ago}d"
304
+ lbl.chars.each_with_index { |c, li| axis[ci * 2 + li] = c if ci * 2 + li < axis.size }
305
+ end
306
+ "today".chars.each_with_index { |c, li| axis[(n-1)*2+li] = c } if axis.all?(" ")
307
+ lines << " " + dim(axis.join.rstrip)
308
+ lines
309
+ end
310
+
311
+ # ── Heatmap ───────────────────────────────────────────────────────────
312
+
313
+ def heatmap_lines
314
+ grid = @m[:hour_day_grid]
315
+ max_val = [grid.flatten.max, 1].max.to_f
316
+ step = 3
317
+ hlabels = (0..21).step(step).map { |h| h.to_s.rjust(3) }.join
318
+ lines = [" #{dim(" #{hlabels}")}"]
319
+ DAY_WDAY.each_with_index do |wday, i|
320
+ cells = (0..21).step(step).map { |h| heat_block(grid[wday][h], max_val) }.join(" ")
321
+ lines << " #{cyan(DAY_NAMES[i])} #{cells}"
322
+ end
323
+ lines
324
+ end
325
+
326
+ def heat_block(count, max)
327
+ r = count.to_f / max
328
+ if r == 0 then dim("░")
329
+ elsif r < 0.33 then blue("▒")
330
+ elsif r < 0.66 then cyan("▓")
331
+ else bright_green("█")
332
+ end
333
+ end
334
+
335
+ # ── Work mode bars ────────────────────────────────────────────────────
336
+
337
+ def wbar(label, pct, color, bw)
338
+ bw = [bw, 1].max
339
+ filled = [(pct.to_f / 100 * bw).round, 0].max
340
+ empty = [bw - filled, 0].max
341
+ bar = @p.send(color, "█" * filled) + dim("░" * empty)
342
+ pct_s = @p.send(color, "#{pct}%".rjust(4))
343
+ " #{label.ljust(7)} #{pct_s} #{bar}"
344
+ end
345
+
346
+ # ── Row primitives ────────────────────────────────────────────────────
347
+
348
+ def cr(text, width)
349
+ { type: CONTENT, text: pad(text, width) }
350
+ end
351
+
352
+ def divr
353
+ { type: DIVIDER }
354
+ end
355
+
356
+ def fit(rows, target, width)
357
+ blank = cr("", width)
358
+ rows = rows.first(target) if rows.size > target
359
+ rows += Array.new([target - rows.size, 0].max, blank)
360
+ rows
361
+ end
362
+
363
+ # ── Text helpers ──────────────────────────────────────────────────────
364
+
365
+ def kv(key, value_s, width)
366
+ overhead = 13 # " " + key.ljust(9) + " " = 2+9+2
367
+ max_vw = width - overhead
368
+ val_s = vis(value_s) > max_vw ? plain_truncate(value_s, max_vw) : value_s
369
+ " #{dim(key.ljust(9))} #{val_s}"
370
+ end
371
+
372
+ def ind(text)
373
+ " #{dim(text)}"
374
+ end
375
+
376
+ def pad(text, width)
377
+ len = vis(text)
378
+ return plain_truncate(text, width) if len > width
379
+ text + " " * (width - len)
380
+ end
381
+
382
+ def plain_truncate(text, max)
383
+ # Measure visible length of text; if exceeds max, cut plain text
384
+ plain = strip_ansi(text)
385
+ return text if plain.length <= max
386
+ plain[0, max - 1] + "…"
387
+ end
388
+
389
+ def vis(s)
390
+ strip_ansi(s).length
391
+ end
392
+
393
+ def strip_ansi(s)
394
+ s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
395
+ .gsub(/[\u{1F300}-\u{1FFFF}]|[\u{2600}-\u{27BF}]|[\u{FE00}-\u{FE0F}]/, "XX")
396
+ .gsub(/[^\x00-\x7F]/, "X")
397
+ end
398
+
399
+ def fmt(n)
400
+ return "0" unless n && n > 0
401
+ n >= 1_000_000 ? "%.1fM" % (n / 1_000_000.0) :
402
+ n >= 1_000 ? "%.1fK" % (n / 1_000.0) : n.to_s
403
+ end
404
+
405
+ def format_dur(secs)
406
+ return "—" unless secs && secs > 0
407
+ h = secs / 3600; m = (secs % 3600) / 60
408
+ h > 0 ? "#{h}h #{m}m" : "#{m}m"
409
+ end
410
+
411
+ def time_ago(t)
412
+ return "—" unless t
413
+ diff = Time.now - t
414
+ case diff
415
+ when 0..60 then "now"
416
+ when 61..3600 then "#{(diff / 60).to_i}m ago"
417
+ when 3601..86400 then "#{(diff / 3600).to_i}h ago"
418
+ when 86401..604800 then "#{(diff / 86400).to_i}d ago"
419
+ else t.strftime("%b %-d")
420
+ end
421
+ end
422
+
423
+ def cost_color(cost, text)
424
+ if cost >= 10.0 then @p.bold.bright_red(text)
425
+ elsif cost >= 3.0 then @p.yellow(text)
426
+ else @p.green(text)
427
+ end
428
+ end
429
+
430
+ def streak_color(n, text)
431
+ if n >= 14 then @p.bold.bright_red(text)
432
+ elsif n >= 7 then @p.bold.bright_yellow(text)
433
+ elsif n >= 3 then @p.yellow(text)
434
+ elsif n >= 1 then @p.cyan(text)
435
+ else dim(text)
436
+ end
437
+ end
438
+
439
+ # ── Color aliases ─────────────────────────────────────────────────────
440
+
441
+ def bold(s) = @p.bold(s)
442
+ def dim(s) = @p.dim(s)
443
+ def cyan(s) = @p.cyan(s)
444
+ def bright_cyan(s) = @p.bright_cyan(s)
445
+ def green(s) = @p.green(s)
446
+ def bright_green(s) = @p.bright_green(s)
447
+ def yellow(s) = @p.yellow(s)
448
+ def bright_yellow(s) = @p.bright_yellow(s)
449
+ def blue(s) = @p.blue(s)
450
+ def bright_white(s) = @p.bright_white(s)
451
+ end
452
+ end
453
+ end
@@ -0,0 +1,34 @@
1
+ require_relative "claude_matrix/version"
2
+ require_relative "claude_matrix/readers/stats_reader"
3
+ require_relative "claude_matrix/readers/session_parser"
4
+ require_relative "claude_matrix/readers/history_reader"
5
+ require_relative "claude_matrix/analyzers/metrics"
6
+ require_relative "claude_matrix/visualizers/dashboard"
7
+
8
+ module ClaudeMatrix
9
+
10
+ def self.load_sessions(verbose: false)
11
+ files = Readers::SessionParser.jsonl_files
12
+ if files.empty?
13
+ warn "No Claude Code session files found in ~/.claude/projects/"
14
+ return []
15
+ end
16
+
17
+ if verbose
18
+ $stderr.print "Scanning #{files.size} session files..."
19
+ end
20
+
21
+ sessions = []
22
+ files.each_with_index do |file, i|
23
+ if verbose && (i % 10 == 0)
24
+ $stderr.print "\rParsing sessions... #{i+1}/#{files.size}"
25
+ $stderr.flush
26
+ end
27
+ s = Readers::SessionParser.parse_file(file)
28
+ sessions << s if s
29
+ end
30
+
31
+ $stderr.puts "\r#{' ' * 50}\r" if verbose
32
+ sessions
33
+ end
34
+ end