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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +183 -0
- data/bin/claude-matrix +7 -0
- data/lib/claude_matrix/analyzers/metrics.rb +206 -0
- data/lib/claude_matrix/cli.rb +177 -0
- data/lib/claude_matrix/readers/history_reader.rb +38 -0
- data/lib/claude_matrix/readers/session_parser.rb +111 -0
- data/lib/claude_matrix/readers/stats_reader.rb +20 -0
- data/lib/claude_matrix/version.rb +3 -0
- data/lib/claude_matrix/visualizers/dashboard.rb +453 -0
- data/lib/claude_matrix.rb +34 -0
- metadata +132 -0
|
@@ -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,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
|