codebuddy-stats 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.
package/src/index.ts ADDED
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env node
2
+
3
+ import blessed from 'blessed'
4
+
5
+ import { loadUsageData } from './lib/data-loader.js'
6
+ import type { AnalysisData } from './lib/data-loader.js'
7
+ import { shortenProjectName } from './lib/paths.js'
8
+ import { formatCost, formatNumber, formatPercent, formatTokens, truncate } from './lib/utils.js'
9
+
10
+ type CliOptions = {
11
+ days: number | null
12
+ noTui: boolean
13
+ }
14
+
15
+ // 解析命令行参数
16
+ function parseArgs(): CliOptions {
17
+ const args = process.argv.slice(2)
18
+ const options: CliOptions = { days: null, noTui: false }
19
+
20
+ for (let i = 0; i < args.length; i++) {
21
+ if (args[i] === '--days' && args[i + 1]) {
22
+ const parsed = Number.parseInt(args[i + 1]!, 10)
23
+ options.days = Number.isFinite(parsed) ? parsed : null
24
+ i++
25
+ } else if (args[i] === '--no-tui') {
26
+ options.noTui = true
27
+ } else if (args[i] === '--help' || args[i] === '-h') {
28
+ console.log(`
29
+ CodeBuddy Cost Analyzer
30
+
31
+ Usage: cost-analyzer [options]
32
+
33
+ Options:
34
+ --days <n> 只显示最近 n 天的数据
35
+ --no-tui 使用纯文本输出(不启用交互式界面)
36
+ --help, -h 显示帮助信息
37
+ `)
38
+ process.exit(0)
39
+ }
40
+ }
41
+ return options
42
+ }
43
+
44
+ type HeatmapData = {
45
+ dates: string[]
46
+ costs: number[]
47
+ maxCost: number
48
+ }
49
+
50
+ // 生成热力图数据
51
+ function generateHeatmapData(dailySummary: AnalysisData['dailySummary']): HeatmapData {
52
+ const sortedDates = Object.keys(dailySummary).sort()
53
+ if (sortedDates.length === 0) return { dates: [], costs: [], maxCost: 0 }
54
+
55
+ const costs = sortedDates.map(d => dailySummary[d]?.cost ?? 0)
56
+ const maxCost = Math.max(...costs)
57
+
58
+ return {
59
+ dates: sortedDates,
60
+ costs,
61
+ maxCost,
62
+ }
63
+ }
64
+
65
+ // 获取热力图字符
66
+ function getHeatChar(cost: number, maxCost: number): string {
67
+ if (cost === 0) return '·'
68
+ const ratio = cost / maxCost
69
+ if (ratio < 0.25) return '░'
70
+ if (ratio < 0.5) return '▒'
71
+ if (ratio < 0.75) return '▓'
72
+ return '█'
73
+ }
74
+
75
+ // 渲染 Overview 视图
76
+ function renderOverview(box: any, data: AnalysisData, width: number): void {
77
+ const { dailySummary, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data
78
+ const heatmap = generateHeatmapData(dailySummary)
79
+
80
+ // 根据宽度计算热力图周数
81
+ const availableWidth = width - 10
82
+ const maxWeeks = Math.min(Math.floor(availableWidth / 2), 26) // 最多 26 周 (半年)
83
+
84
+ let content = '{bold}Cost Heatmap{/bold}\n\n'
85
+
86
+ // 生成正确的日期网格 - 从今天往前推算
87
+ const today = new Date()
88
+ const todayStr = today.toISOString().split('T')[0]!
89
+
90
+ // 找到最近的周六作为结束点(或今天)
91
+ const endDate = new Date(today)
92
+
93
+ // 往前推 maxWeeks 周
94
+ const startDate = new Date(endDate)
95
+ startDate.setDate(startDate.getDate() - maxWeeks * 7 + 1)
96
+ // 调整到周日开始
97
+ startDate.setDate(startDate.getDate() - startDate.getDay())
98
+
99
+ // 构建周数组,每周从周日到周六
100
+ const weeks: string[][] = []
101
+ const currentDate = new Date(startDate)
102
+ while (currentDate <= endDate) {
103
+ const week: string[] = []
104
+ for (let d = 0; d < 7; d++) {
105
+ const dateStr = currentDate.toISOString().split('T')[0]!
106
+ week.push(dateStr)
107
+ currentDate.setDate(currentDate.getDate() + 1)
108
+ }
109
+ weeks.push(week)
110
+ }
111
+
112
+ const maxCost = heatmap.maxCost || 1
113
+ const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
114
+
115
+ for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
116
+ let row = dayLabels[dayOfWeek]!.padEnd(4)
117
+ for (const week of weeks) {
118
+ const date = week[dayOfWeek]
119
+ if (date && date <= todayStr && dailySummary[date]) {
120
+ row += getHeatChar(dailySummary[date]!.cost, maxCost) + ' '
121
+ } else if (date && date <= todayStr) {
122
+ row += '· ' // 有日期但无数据
123
+ } else {
124
+ row += ' ' // 未来日期
125
+ }
126
+ }
127
+ content += row + '\n'
128
+ }
129
+
130
+ content += ' Less {gray-fg}·░▒▓{/gray-fg}{white-fg}█{/white-fg} More\n\n'
131
+
132
+ // 汇总指标 - 根据宽度决定布局
133
+ const avgDailyCost = activeDays > 0 ? grandTotal.cost / activeDays : 0
134
+ const summaryWidth = Math.min(width - 6, 70)
135
+
136
+ content += '{bold}Summary{/bold}\n'
137
+ content += '─'.repeat(summaryWidth) + '\n'
138
+
139
+ if (width >= 80) {
140
+ // 双列布局
141
+ content += `{green-fg}Total cost:{/green-fg} ${formatCost(grandTotal.cost).padStart(12)} `
142
+ content += `{green-fg}Active days:{/green-fg} ${String(activeDays).padStart(8)}\n`
143
+ content += `{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens).padStart(12)} `
144
+ content += `{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests).padStart(8)}\n`
145
+ content += `{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate).padStart(12)} `
146
+ content += `{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost).padStart(8)}\n\n`
147
+ } else {
148
+ // 单列布局
149
+ content += `{green-fg}Total cost:{/green-fg} ${formatCost(grandTotal.cost)}\n`
150
+ content += `{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens)}\n`
151
+ content += `{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests)}\n`
152
+ content += `{green-fg}Active days:{/green-fg} ${activeDays}\n`
153
+ content += `{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate)}\n`
154
+ content += `{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost)}\n\n`
155
+ }
156
+
157
+ if (topModel) {
158
+ content += `{cyan-fg}Top model:{/cyan-fg} ${topModel.id} (${formatCost(topModel.cost)})\n`
159
+ }
160
+ if (topProject) {
161
+ const projectMaxLen = width >= 100 ? 60 : 35
162
+ const shortName = shortenProjectName(topProject.name)
163
+ content += `{cyan-fg}Top project:{/cyan-fg} ${truncate(shortName, projectMaxLen)} (${formatCost(topProject.cost)})\n`
164
+ }
165
+
166
+ box.setContent(content)
167
+ }
168
+
169
+ // 渲染 By Model 视图
170
+ function renderByModel(box: any, data: AnalysisData, width: number): void {
171
+ const { modelTotals, grandTotal } = data
172
+ const sorted = Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost)
173
+
174
+ // 根据宽度计算列宽
175
+ const availableWidth = width - 6 // padding
176
+ const fixedCols = 12 + 12 + 12 + 10 // Cost + Requests + Tokens + Avg/Req
177
+ const modelCol = Math.max(20, Math.min(40, availableWidth - fixedCols))
178
+ const totalWidth = modelCol + fixedCols
179
+
180
+ let content = '{bold}Cost by Model{/bold}\n\n'
181
+ content +=
182
+ '{underline}' +
183
+ 'Model'.padEnd(modelCol) +
184
+ 'Cost'.padStart(12) +
185
+ 'Requests'.padStart(12) +
186
+ 'Tokens'.padStart(12) +
187
+ 'Avg/Req'.padStart(10) +
188
+ '{/underline}\n'
189
+
190
+ for (const [modelId, stats] of sorted) {
191
+ const avgPerReq = stats.requests > 0 ? stats.cost / stats.requests : 0
192
+ content +=
193
+ truncate(modelId, modelCol - 1).padEnd(modelCol) +
194
+ formatCost(stats.cost).padStart(12) +
195
+ formatNumber(stats.requests).padStart(12) +
196
+ formatTokens(stats.tokens).padStart(12) +
197
+ formatCost(avgPerReq).padStart(10) +
198
+ '\n'
199
+ }
200
+
201
+ content += '─'.repeat(totalWidth) + '\n'
202
+ content +=
203
+ '{bold}' +
204
+ 'Total'.padEnd(modelCol) +
205
+ formatCost(grandTotal.cost).padStart(12) +
206
+ formatNumber(grandTotal.requests).padStart(12) +
207
+ formatTokens(grandTotal.tokens).padStart(12) +
208
+ '{/bold}\n'
209
+
210
+ box.setContent(content)
211
+ }
212
+
213
+ // 渲染 By Project 视图
214
+ function renderByProject(box: any, data: AnalysisData, width: number): void {
215
+ const { projectTotals, grandTotal } = data
216
+ const sorted = Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost)
217
+
218
+ // 根据宽度计算列宽
219
+ const availableWidth = width - 6 // padding
220
+ const fixedCols = 12 + 12 + 12 // Cost + Requests + Tokens
221
+ const projectCol = Math.max(25, availableWidth - fixedCols)
222
+ const totalWidth = projectCol + fixedCols
223
+
224
+ let content = '{bold}Cost by Project{/bold}\n\n'
225
+ content +=
226
+ '{underline}' +
227
+ 'Project'.padEnd(projectCol) +
228
+ 'Cost'.padStart(12) +
229
+ 'Requests'.padStart(12) +
230
+ 'Tokens'.padStart(12) +
231
+ '{/underline}\n'
232
+
233
+ for (const [projectName, stats] of sorted) {
234
+ // 简化项目名
235
+ const shortName = shortenProjectName(projectName)
236
+ content +=
237
+ truncate(shortName, projectCol - 1).padEnd(projectCol) +
238
+ formatCost(stats.cost).padStart(12) +
239
+ formatNumber(stats.requests).padStart(12) +
240
+ formatTokens(stats.tokens).padStart(12) +
241
+ '\n'
242
+ }
243
+
244
+ content += '─'.repeat(totalWidth) + '\n'
245
+ content +=
246
+ '{bold}' +
247
+ `Total (${sorted.length} projects)`.padEnd(projectCol) +
248
+ formatCost(grandTotal.cost).padStart(12) +
249
+ formatNumber(grandTotal.requests).padStart(12) +
250
+ formatTokens(grandTotal.tokens).padStart(12) +
251
+ '{/bold}\n'
252
+
253
+ box.setContent(content)
254
+ }
255
+
256
+ // 渲染 Daily 视图
257
+ function renderDaily(box: any, data: AnalysisData, scrollOffset = 0, width: number): void {
258
+ const { dailySummary, dailyData } = data
259
+ const sortedDates = Object.keys(dailySummary).sort().reverse()
260
+
261
+ // 根据宽度计算列宽
262
+ const availableWidth = width - 6 // padding
263
+ const dateCol = 12
264
+ const costCol = 12
265
+ const reqCol = 10
266
+ const fixedCols = dateCol + costCol + reqCol
267
+ const remainingWidth = availableWidth - fixedCols
268
+ const modelCol = Math.max(15, Math.min(25, Math.floor(remainingWidth * 0.4)))
269
+ const projectCol = Math.max(20, remainingWidth - modelCol)
270
+
271
+ let content = '{bold}Daily Cost Details{/bold}\n\n'
272
+ content +=
273
+ '{underline}' +
274
+ 'Date'.padEnd(dateCol) +
275
+ 'Cost'.padStart(costCol) +
276
+ 'Requests'.padStart(reqCol) +
277
+ 'Top Model'.padStart(modelCol) +
278
+ 'Top Project'.padStart(projectCol) +
279
+ '{/underline}\n'
280
+
281
+ const visibleDates = sortedDates.slice(scrollOffset, scrollOffset + 20)
282
+
283
+ for (const date of visibleDates) {
284
+ const daySummary = dailySummary[date]
285
+ const dayData = dailyData[date]
286
+ if (!daySummary || !dayData) continue
287
+
288
+ // 找出当天 top model 和 project
289
+ let topModel: { id: string; cost: number } = { id: '-', cost: 0 }
290
+ let topProject: { name: string; cost: number } = { name: '-', cost: 0 }
291
+
292
+ for (const [project, models] of Object.entries(dayData)) {
293
+ let projectCost = 0
294
+ for (const [model, stats] of Object.entries(models)) {
295
+ const modelStats = stats as any
296
+ projectCost += Number(modelStats.cost ?? 0)
297
+ if (Number(modelStats.cost ?? 0) > topModel.cost) {
298
+ topModel = { id: model, cost: Number(modelStats.cost ?? 0) }
299
+ }
300
+ }
301
+ if (projectCost > topProject.cost) {
302
+ topProject = { name: project, cost: projectCost }
303
+ }
304
+ }
305
+
306
+ const shortProject = shortenProjectName(topProject.name)
307
+
308
+ content +=
309
+ date.padEnd(dateCol) +
310
+ formatCost(daySummary.cost).padStart(costCol) +
311
+ formatNumber(daySummary.requests).padStart(reqCol) +
312
+ truncate(topModel.id, modelCol - 1).padStart(modelCol) +
313
+ truncate(shortProject, projectCol - 1).padStart(projectCol) +
314
+ '\n'
315
+ }
316
+
317
+ if (sortedDates.length > 20) {
318
+ content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + 20, sortedDates.length)} of ${sortedDates.length} days (↑↓ to scroll){/gray-fg}`
319
+ }
320
+
321
+ box.setContent(content)
322
+ }
323
+
324
+ // 纯文本输出模式
325
+ function printTextReport(data: AnalysisData): void {
326
+ const { modelTotals, projectTotals, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data
327
+
328
+ console.log('\n🤖 CodeBuddy Cost Analysis Report')
329
+ console.log('='.repeat(50))
330
+
331
+ console.log(`\nTotal cost: ${formatCost(grandTotal.cost)}`)
332
+ console.log(`Total tokens: ${formatTokens(grandTotal.tokens)}`)
333
+ console.log(`Total requests: ${formatNumber(grandTotal.requests)}`)
334
+ console.log(`Active days: ${activeDays}`)
335
+ console.log(`Cache hit rate: ${formatPercent(cacheHitRate)}`)
336
+
337
+ if (topModel) {
338
+ console.log(`\nTop model: ${topModel.id} (${formatCost(topModel.cost)})`)
339
+ }
340
+ if (topProject) {
341
+ console.log(`Top project: ${topProject.name}`)
342
+ console.log(` (${formatCost(topProject.cost)})`)
343
+ }
344
+
345
+ console.log('\n' + '-'.repeat(50))
346
+ console.log('By Model:')
347
+ for (const [model, stats] of Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost)) {
348
+ console.log(` ${model}: ${formatCost(stats.cost)} (${formatNumber(stats.requests)} req)`) // eslint-disable-line no-console
349
+ }
350
+
351
+ console.log('\n' + '-'.repeat(50))
352
+ console.log('By Project:')
353
+ for (const [project, stats] of Object.entries(projectTotals)
354
+ .sort((a, b) => b[1].cost - a[1].cost)
355
+ .slice(0, 10)) {
356
+ const shortName = shortenProjectName(project)
357
+ console.log(` ${truncate(shortName, 40)}: ${formatCost(stats.cost)}`) // eslint-disable-line no-console
358
+ }
359
+
360
+ console.log('\n' + '='.repeat(50) + '\n')
361
+ }
362
+
363
+ // 主程序
364
+ async function main(): Promise<void> {
365
+ const options = parseArgs()
366
+
367
+ console.log('Loading data...')
368
+ let data = await loadUsageData({ days: options.days })
369
+
370
+ if (options.noTui) {
371
+ printTextReport(data)
372
+ return
373
+ }
374
+
375
+ // 创建 TUI
376
+ const screen = blessed.screen({
377
+ smartCSR: true,
378
+ title: 'CodeBuddy Cost Analyzer',
379
+ })
380
+
381
+ // Tab 状态
382
+ const tabs = ['Overview', 'By Model', 'By Project', 'Daily']
383
+ let currentTab = 0
384
+ let dailyScrollOffset = 0
385
+
386
+ // Tab 栏
387
+ const tabBar = blessed.box({
388
+ top: 0,
389
+ left: 0,
390
+ width: '100%',
391
+ height: 3,
392
+ tags: true,
393
+ style: {
394
+ fg: 'white',
395
+ bg: 'black',
396
+ },
397
+ })
398
+
399
+ // 内容区域
400
+ const contentBox = blessed.box({
401
+ top: 3,
402
+ left: 0,
403
+ width: '100%',
404
+ height: '100%-5',
405
+ tags: true,
406
+ scrollable: true,
407
+ alwaysScroll: true,
408
+ keys: true,
409
+ vi: true,
410
+ style: {
411
+ fg: 'white',
412
+ bg: 'black',
413
+ },
414
+ padding: {
415
+ left: 2,
416
+ right: 2,
417
+ top: 1,
418
+ },
419
+ })
420
+
421
+ // 底部状态栏
422
+ const statusBar = blessed.box({
423
+ bottom: 0,
424
+ left: 0,
425
+ width: '100%',
426
+ height: 1,
427
+ tags: true,
428
+ style: {
429
+ fg: 'black',
430
+ bg: 'green',
431
+ },
432
+ })
433
+
434
+ screen.append(tabBar)
435
+ screen.append(contentBox)
436
+ screen.append(statusBar)
437
+
438
+ // 更新 Tab 栏
439
+ function updateTabBar(): void {
440
+ let content = ' Cost Analysis '
441
+ for (let i = 0; i < tabs.length; i++) {
442
+ if (i === currentTab) {
443
+ content += `{black-fg}{green-bg} ${tabs[i]} {/green-bg}{/black-fg} `
444
+ } else {
445
+ content += `{gray-fg}${tabs[i]}{/gray-fg} `
446
+ }
447
+ }
448
+ content += ' {gray-fg}(Tab to switch){/gray-fg}'
449
+ tabBar.setContent(content)
450
+ }
451
+
452
+ // 更新内容
453
+ function updateContent(): void {
454
+ const width = Number(screen.width) || 80
455
+ switch (currentTab) {
456
+ case 0:
457
+ renderOverview(contentBox, data, width)
458
+ break
459
+ case 1:
460
+ renderByModel(contentBox, data, width)
461
+ break
462
+ case 2:
463
+ renderByProject(contentBox, data, width)
464
+ break
465
+ case 3:
466
+ renderDaily(contentBox, data, dailyScrollOffset, width)
467
+ break
468
+ }
469
+ }
470
+
471
+ // 更新状态栏
472
+ function updateStatusBar(): void {
473
+ const daysInfo = options.days ? `Last ${options.days} days` : 'All time'
474
+ statusBar.setContent(
475
+ ` ${daysInfo} | Total: ${formatCost(data.grandTotal.cost)} | q quit, Tab switch, r refresh`
476
+ )
477
+ }
478
+
479
+ // 键盘事件
480
+ screen.key(['tab'], () => {
481
+ currentTab = (currentTab + 1) % tabs.length
482
+ dailyScrollOffset = 0
483
+ updateTabBar()
484
+ updateContent()
485
+ screen.render()
486
+ })
487
+
488
+ screen.key(['S-tab'], () => {
489
+ currentTab = (currentTab - 1 + tabs.length) % tabs.length
490
+ dailyScrollOffset = 0
491
+ updateTabBar()
492
+ updateContent()
493
+ screen.render()
494
+ })
495
+
496
+ screen.key(['up', 'k'], () => {
497
+ if (currentTab === 3) {
498
+ dailyScrollOffset = Math.max(0, dailyScrollOffset - 1)
499
+ updateContent()
500
+ screen.render()
501
+ }
502
+ })
503
+
504
+ screen.key(['down', 'j'], () => {
505
+ if (currentTab === 3) {
506
+ const maxOffset = Math.max(0, Object.keys(data.dailySummary).length - 20)
507
+ dailyScrollOffset = Math.min(maxOffset, dailyScrollOffset + 1)
508
+ updateContent()
509
+ screen.render()
510
+ }
511
+ })
512
+
513
+ screen.key(['q', 'C-c'], () => {
514
+ screen.destroy()
515
+ process.exit(0)
516
+ })
517
+
518
+ screen.key(['r'], async () => {
519
+ statusBar.setContent(' {yellow-fg}Reloading...{/yellow-fg}')
520
+ screen.render()
521
+ try {
522
+ data = await loadUsageData({ days: options.days })
523
+ dailyScrollOffset = 0
524
+ updateTabBar()
525
+ updateContent()
526
+ updateStatusBar()
527
+ } catch (err) {
528
+ statusBar.setContent(` {red-fg}Reload failed: ${String(err)}{/red-fg}`)
529
+ }
530
+ screen.render()
531
+ })
532
+
533
+ // 监听窗口大小变化
534
+ screen.on('resize', () => {
535
+ updateContent()
536
+ screen.render()
537
+ })
538
+
539
+ // 初始渲染
540
+ updateTabBar()
541
+ updateContent()
542
+ updateStatusBar()
543
+ screen.render()
544
+ }
545
+
546
+ main().catch(err => {
547
+ console.error('Error:', err)
548
+ process.exit(1)
549
+ })