codebuddy-stats 1.2.12 → 1.3.1

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/dist/index.js CHANGED
@@ -6,6 +6,15 @@ import blessed from 'blessed';
6
6
  import { loadUsageData } from './lib/data-loader.js';
7
7
  import { resolveProjectName } from './lib/workspace-resolver.js';
8
8
  import { compareByCostThenTokens, formatCost, formatNumber, formatPercent, formatTokens, truncate } from './lib/utils.js';
9
+ import { renderByModel } from './views/by-model.js';
10
+ import { renderByProject } from './views/by-project.js';
11
+ import { renderDaily } from './views/daily.js';
12
+ import { renderDailyDetail } from './views/daily-detail.js';
13
+ import { renderMonthly } from './views/monthly.js';
14
+ import { renderMonthlyDetail } from './views/monthly-detail.js';
15
+ import { renderOverview } from './views/overview.js';
16
+ import { registerAppKeybindings } from './tui/keybindings.js';
17
+ import { createInitialState } from './tui/state.js';
9
18
  // 读取 package.json 获取版本号
10
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
20
  const pkgPath = path.resolve(__dirname, '../package.json');
@@ -40,494 +49,14 @@ Options:
40
49
  }
41
50
  return options;
42
51
  }
43
- // 获取热力图字符
44
- function getHeatChar(cost, maxCost) {
45
- if (cost === 0)
46
- return '·';
47
- const ratio = cost / maxCost;
48
- if (ratio < 0.25)
49
- return '░';
50
- if (ratio < 0.5)
51
- return '▒';
52
- if (ratio < 0.75)
53
- return '▓';
54
- return '█';
55
- }
56
- // 渲染 Overview 视图
57
- function renderOverview(box, data, width, height, note) {
58
- const { dailySummary, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data;
59
- const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
60
- const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
61
- const stripTags = (s) => s.replace(/\{[^}]+\}/g, '');
62
- const visibleLen = (s) => stripTags(s).length;
63
- const padEndVisible = (s, target) => {
64
- const pad = Math.max(0, target - visibleLen(s));
65
- return s + ' '.repeat(pad);
66
- };
67
- const wrapGrayNoteLines = (text, maxWidth) => {
68
- const prefix = '{gray-fg}';
69
- const suffix = '{/gray-fg}';
70
- const full = `备注:${text}`;
71
- const w = Math.max(10, Math.floor(maxWidth || 10));
72
- const lines = [];
73
- let i = 0;
74
- while (i < full.length) {
75
- const chunk = full.slice(i, i + w);
76
- lines.push(prefix + chunk + suffix);
77
- i += w;
78
- }
79
- return lines;
80
- };
81
- const buildHeatmapLines = (heatWidth) => {
82
- const safeWidth = Math.max(30, Math.floor(heatWidth || 30));
83
- // 根据宽度计算热力图周数
84
- const availableWidth = safeWidth - 10;
85
- const maxWeeks = Math.min(Math.floor(availableWidth / 2), 26); // 最多 26 周 (半年)
86
- // 生成正确的日期网格 - 从今天往前推算
87
- const today = new Date();
88
- const todayStr = today.toISOString().split('T')[0];
89
- // 找到最近的周六作为结束点(或今天)
90
- const endDate = new Date(today);
91
- // 往前推 maxWeeks 周
92
- const startDate = new Date(endDate);
93
- startDate.setDate(startDate.getDate() - maxWeeks * 7 + 1);
94
- // 调整到周一开始(getDay(): 0=Sun, 1=Mon, ..., 6=Sat)
95
- const dayOfWeekStart = startDate.getDay();
96
- const offsetToMonday = dayOfWeekStart === 0 ? -6 : 1 - dayOfWeekStart;
97
- startDate.setDate(startDate.getDate() + offsetToMonday);
98
- // 构建周数组,每周从周一到周日
99
- const weeks = [];
100
- const currentDate = new Date(startDate);
101
- while (currentDate <= endDate) {
102
- const week = [];
103
- for (let d = 0; d < 7; d++) {
104
- const dateStr = currentDate.toISOString().split('T')[0];
105
- week.push(dateStr);
106
- currentDate.setDate(currentDate.getDate() + 1);
107
- }
108
- weeks.push(week);
109
- }
110
- // 以“当前热力图窗口”的最大值做归一化(避免历史极值导致近期全是浅色)
111
- const visibleCosts = [];
112
- for (const week of weeks) {
113
- for (const date of week) {
114
- if (!date || date > todayStr)
115
- continue;
116
- visibleCosts.push(dailySummary[date]?.cost ?? 0);
117
- }
118
- }
119
- const maxCost = Math.max(...visibleCosts, 0) || 1;
120
- // 月份标尺(在列上方标注月份变化)
121
- const colWidth = 2; // 每周一列:字符 + 空格
122
- const heatStartCol = 4; // 左侧周几标签宽度
123
- const headerLen = heatStartCol + weeks.length * colWidth;
124
- const monthHeader = Array.from({ length: headerLen }, () => ' ');
125
- let lastMonth = -1;
126
- let lastPlacedAt = -999;
127
- for (let i = 0; i < weeks.length; i++) {
128
- const week = weeks[i];
129
- const repDate = week.find(d => d && d <= todayStr) ?? week[0];
130
- if (!repDate)
131
- continue;
132
- const m = new Date(repDate).getMonth();
133
- if (m !== lastMonth) {
134
- const label = monthNames[m];
135
- const pos = heatStartCol + i * colWidth;
136
- // 避免月份标签过于拥挤/相互覆盖
137
- if (pos - lastPlacedAt >= 4 && pos + label.length <= monthHeader.length) {
138
- for (let k = 0; k < label.length; k++)
139
- monthHeader[pos + k] = label[k];
140
- lastPlacedAt = pos;
141
- }
142
- lastMonth = m;
143
- }
144
- }
145
- const lines = [];
146
- lines.push('{bold}Cost Heatmap{/bold}');
147
- lines.push('');
148
- lines.push(`{gray-fg}${monthHeader.join('').trimEnd()}{/gray-fg}`);
149
- for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
150
- let row = dayLabels[dayOfWeek].padEnd(4);
151
- for (const week of weeks) {
152
- const date = week[dayOfWeek];
153
- if (date && date <= todayStr && dailySummary[date]) {
154
- row += getHeatChar(dailySummary[date].cost, maxCost) + ' ';
155
- }
156
- else if (date && date <= todayStr) {
157
- row += '· '; // 有日期但无数据
158
- }
159
- else {
160
- row += ' '; // 未来日期
161
- }
162
- }
163
- lines.push(row.trimEnd());
164
- }
165
- const rangeStart = weeks[0]?.[0] ?? todayStr;
166
- lines.push(`{gray-fg}Range: ${rangeStart} → ${todayStr}{/gray-fg}`);
167
- lines.push(' Less {gray-fg}·░▒▓{/gray-fg}{white-fg}█{/white-fg} More');
168
- return lines;
169
- };
170
- const buildSummaryLines = (summaryWidth, compact) => {
171
- const avgDailyCost = activeDays > 0 ? grandTotal.cost / activeDays : 0;
172
- const w = Math.max(24, Math.floor(summaryWidth || 24));
173
- const maxW = Math.min(w, 80);
174
- const lines = [];
175
- lines.push('{bold}Summary{/bold}');
176
- if (!compact)
177
- lines.push('─'.repeat(Math.min(Math.max(10, maxW - 2), 70)));
178
- const twoCol = maxW >= 46;
179
- if (twoCol) {
180
- const leftLabelW = 18;
181
- const rightLabelW = 18;
182
- const leftValW = 12;
183
- const rightValW = 8;
184
- const leftPartW = leftLabelW + leftValW + 4;
185
- lines.push(padEndVisible(padEndVisible('{green-fg}~Total cost:{/green-fg}', leftLabelW) + formatCost(grandTotal.cost).padStart(leftValW), leftPartW) +
186
- padEndVisible(' {green-fg}Active days:{/green-fg}', rightLabelW) +
187
- String(activeDays).padStart(rightValW));
188
- lines.push(padEndVisible(padEndVisible('{green-fg}Total tokens:{/green-fg}', leftLabelW) +
189
- formatTokens(grandTotal.tokens).padStart(leftValW), leftPartW) +
190
- padEndVisible(' {green-fg}Total requests:{/green-fg}', rightLabelW) +
191
- formatNumber(grandTotal.requests).padStart(rightValW));
192
- lines.push(padEndVisible(padEndVisible('{green-fg}Cache hit rate:{/green-fg}', leftLabelW) +
193
- formatPercent(cacheHitRate).padStart(leftValW), leftPartW) +
194
- padEndVisible(' {green-fg}Avg daily cost:{/green-fg}', rightLabelW) +
195
- formatCost(avgDailyCost).padStart(rightValW));
196
- }
197
- else {
198
- lines.push(`{green-fg}~Total cost:{/green-fg} ${formatCost(grandTotal.cost)}`);
199
- lines.push(`{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens)}`);
200
- lines.push(`{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests)}`);
201
- lines.push(`{green-fg}Active days:{/green-fg} ${activeDays}`);
202
- lines.push(`{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate)}`);
203
- lines.push(`{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost)}`);
204
- }
205
- if (!compact)
206
- lines.push('');
207
- if (topModel) {
208
- const label = '{cyan-fg}Top model:{/cyan-fg} ';
209
- const tail = `(${formatCost(topModel.cost)})`;
210
- const maxIdLen = Math.max(4, maxW - visibleLen(label) - visibleLen(tail) - 2);
211
- lines.push(label + truncate(topModel.id, maxIdLen) + ' ' + tail);
212
- }
213
- if (topProject) {
214
- const label = '{cyan-fg}Top project:{/cyan-fg} ';
215
- const shortName = resolveProjectName(topProject.name, data.workspaceMappings);
216
- const tail = `(${formatCost(topProject.cost)})`;
217
- const maxNameLen = Math.max(4, maxW - visibleLen(label) - visibleLen(tail) - 2);
218
- lines.push(label + truncate(shortName, maxNameLen) + ' ' + tail);
219
- }
220
- return lines;
221
- };
222
- const noteLines = note ? wrapGrayNoteLines(note, Math.max(20, width - 6)) : [];
223
- // 尝试默认:热力图在上,Summary 在下
224
- const verticalHeat = buildHeatmapLines(width);
225
- const verticalSummary = buildSummaryLines(width, false);
226
- const verticalLines = [...verticalHeat, '', ...verticalSummary];
227
- if (noteLines.length)
228
- verticalLines.push('', ...noteLines);
229
- if (verticalLines.length <= height) {
230
- box.setContent(verticalLines.join('\n'));
231
- return;
232
- }
233
- // 终端偏矮:尝试把 Summary 放到右侧(需要足够宽度)
234
- const gap = 6;
235
- const minSummaryWidth = 34;
236
- const leftWidthBudget = Math.max(30, width - minSummaryWidth - gap);
237
- const leftHeat = buildHeatmapLines(leftWidthBudget);
238
- const leftVisibleWidth = Math.max(...leftHeat.map(l => visibleLen(l)), 0);
239
- const rightWidth = Math.max(0, width - leftVisibleWidth - gap);
240
- if (rightWidth >= minSummaryWidth) {
241
- const rightSummary = buildSummaryLines(rightWidth, true);
242
- const rowCount = Math.max(leftHeat.length, rightSummary.length);
243
- const sideLines = [];
244
- for (let i = 0; i < rowCount; i++) {
245
- const l = leftHeat[i] ?? '';
246
- const r = rightSummary[i] ?? '';
247
- sideLines.push(padEndVisible(l, leftVisibleWidth) + ' '.repeat(gap) + r);
248
- }
249
- if (noteLines.length)
250
- sideLines.push('', ...noteLines);
251
- if (sideLines.length <= height) {
252
- box.setContent(sideLines.join('\n'));
253
- return;
254
- }
255
- }
256
- // fallback:仍然输出纵向布局(可滚动)
257
- box.setContent(verticalLines.join('\n'));
258
- }
259
- // 渲染 By Model 视图
260
- function renderByModel(box, data, scrollOffset = 0, width, note, pageSize) {
261
- const { modelTotals, grandTotal } = data;
262
- const sorted = Object.entries(modelTotals).sort(compareByCostThenTokens);
263
- // 根据宽度计算列宽
264
- const availableWidth = width - 6; // padding
265
- const fixedCols = 12 + 12 + 12 + 10; // Cost + Requests + Tokens + Avg/Req
266
- const modelCol = Math.max(20, Math.min(40, availableWidth - fixedCols));
267
- const totalWidth = modelCol + fixedCols;
268
- let content = '{bold}Cost by Model{/bold}\n\n';
269
- content +=
270
- '{underline}' +
271
- 'Model'.padEnd(modelCol) +
272
- '~Cost'.padStart(12) +
273
- 'Requests'.padStart(12) +
274
- 'Tokens'.padStart(12) +
275
- 'Avg/Req'.padStart(10) +
276
- '{/underline}\n';
277
- const safePageSize = Math.max(1, Math.floor(pageSize || 1));
278
- const visibleModels = sorted.slice(scrollOffset, scrollOffset + safePageSize);
279
- for (const [modelId, stats] of visibleModels) {
280
- const avgPerReq = stats.requests > 0 ? stats.cost / stats.requests : 0;
281
- content +=
282
- truncate(modelId, modelCol - 1).padEnd(modelCol) +
283
- formatCost(stats.cost).padStart(12) +
284
- formatNumber(stats.requests).padStart(12) +
285
- formatTokens(stats.tokens).padStart(12) +
286
- formatCost(avgPerReq).padStart(10) +
287
- '\n';
288
- }
289
- content += '─'.repeat(totalWidth) + '\n';
290
- content +=
291
- '{bold}' +
292
- 'Total'.padEnd(modelCol) +
293
- formatCost(grandTotal.cost).padStart(12) +
294
- formatNumber(grandTotal.requests).padStart(12) +
295
- formatTokens(grandTotal.tokens).padStart(12) +
296
- '{/bold}\n';
297
- if (sorted.length > safePageSize) {
298
- content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + safePageSize, sorted.length)} of ${sorted.length} models (↑↓ to scroll){/gray-fg}`;
299
- }
300
- if (note) {
301
- content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
302
- }
303
- box.setContent(content);
304
- }
305
- // 渲染 By Project 视图
306
- function renderByProject(box, data, scrollOffset = 0, width, note, pageSize) {
307
- const { projectTotals, grandTotal } = data;
308
- const sorted = Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost);
309
- // 根据宽度计算列宽
310
- const availableWidth = width - 6; // padding
311
- const fixedCols = 12 + 12 + 12; // Cost + Requests + Tokens
312
- const projectCol = Math.max(25, availableWidth - fixedCols);
313
- const totalWidth = projectCol + fixedCols;
314
- let content = '{bold}Cost by Project{/bold}\n\n';
315
- content +=
316
- '{underline}' +
317
- 'Project'.padEnd(projectCol) +
318
- '~Cost'.padStart(12) +
319
- 'Requests'.padStart(12) +
320
- 'Tokens'.padStart(12) +
321
- '{/underline}\n';
322
- const safePageSize = Math.max(1, Math.floor(pageSize || 1));
323
- const visibleProjects = sorted.slice(scrollOffset, scrollOffset + safePageSize);
324
- for (const [projectName, stats] of visibleProjects) {
325
- // 简化项目名
326
- const shortName = resolveProjectName(projectName, data.workspaceMappings);
327
- content +=
328
- truncate(shortName, projectCol - 1).padEnd(projectCol) +
329
- formatCost(stats.cost).padStart(12) +
330
- formatNumber(stats.requests).padStart(12) +
331
- formatTokens(stats.tokens).padStart(12) +
332
- '\n';
333
- }
334
- content += '─'.repeat(totalWidth) + '\n';
335
- content +=
336
- '{bold}' +
337
- `Total (${sorted.length} projects)`.padEnd(projectCol) +
338
- formatCost(grandTotal.cost).padStart(12) +
339
- formatNumber(grandTotal.requests).padStart(12) +
340
- formatTokens(grandTotal.tokens).padStart(12) +
341
- '{/bold}\n';
342
- if (sorted.length > safePageSize) {
343
- content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + safePageSize, sorted.length)} of ${sorted.length} projects (↑↓ to scroll){/gray-fg}`;
344
- }
345
- if (note) {
346
- content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
347
- }
348
- box.setContent(content);
349
- }
350
- // 渲染 Daily 视图
351
- function renderDaily(box, data, scrollOffset = 0, selectedIndex = 0, width, note, pageSize) {
352
- const { dailySummary, dailyData } = data;
353
- const sortedDates = Object.keys(dailySummary).sort().reverse();
354
- // 根据宽度计算列宽
355
- const availableWidth = width - 6; // padding
356
- const dateCol = 12;
357
- const costCol = 12;
358
- const tokensCol = 10;
359
- const reqCol = 10;
360
- const fixedCols = dateCol + costCol + tokensCol + reqCol;
361
- const remainingWidth = availableWidth - fixedCols;
362
- const modelCol = Math.max(15, Math.min(25, Math.floor(remainingWidth * 0.4)));
363
- const projectCol = Math.max(20, remainingWidth - modelCol);
364
- let content = '{bold}Daily Cost Details{/bold}\n\n';
365
- content +=
366
- '{underline}' +
367
- 'Date'.padEnd(dateCol) +
368
- '~Cost'.padStart(costCol) +
369
- 'Tokens'.padStart(tokensCol) +
370
- 'Requests'.padStart(reqCol) +
371
- 'Top Model'.padStart(modelCol) +
372
- 'Top Project'.padStart(projectCol) +
373
- '{/underline}\n';
374
- const safePageSize = Math.max(1, Math.floor(pageSize || 1));
375
- const visibleDates = sortedDates.slice(scrollOffset, scrollOffset + safePageSize);
376
- for (let i = 0; i < visibleDates.length; i++) {
377
- const date = visibleDates[i];
378
- const daySummary = dailySummary[date];
379
- const dayData = dailyData[date];
380
- if (!daySummary || !dayData)
381
- continue;
382
- // 找出当天 top model 和 project
383
- let topModel = { id: '-', cost: 0 };
384
- let topProject = { name: '-', cost: 0 };
385
- for (const [project, models] of Object.entries(dayData)) {
386
- let projectCost = 0;
387
- for (const [model, stats] of Object.entries(models)) {
388
- const modelStats = stats;
389
- projectCost += Number(modelStats.cost ?? 0);
390
- if (Number(modelStats.cost ?? 0) > topModel.cost) {
391
- topModel = { id: model, cost: Number(modelStats.cost ?? 0) };
392
- }
393
- }
394
- if (projectCost > topProject.cost) {
395
- topProject = { name: project, cost: projectCost };
396
- }
397
- }
398
- const shortProject = resolveProjectName(topProject.name, data.workspaceMappings);
399
- const isSelected = scrollOffset + i === selectedIndex;
400
- const rowContent = date.padEnd(dateCol) +
401
- formatCost(daySummary.cost).padStart(costCol) +
402
- formatTokens(daySummary.tokens).padStart(tokensCol) +
403
- formatNumber(daySummary.requests).padStart(reqCol) +
404
- truncate(topModel.id, modelCol - 1).padStart(modelCol) +
405
- truncate(shortProject, projectCol - 1).padStart(projectCol);
406
- if (isSelected) {
407
- content += `{black-fg}{green-bg}${rowContent}{/green-bg}{/black-fg}\n`;
408
- }
409
- else {
410
- content += rowContent + '\n';
411
- }
412
- }
413
- if (sortedDates.length > safePageSize) {
414
- content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + safePageSize, sortedDates.length)} of ${sortedDates.length} days (↑↓ scroll, Enter detail){/gray-fg}`;
415
- }
416
- else {
417
- content += `\n{gray-fg}(↑↓ select, Enter detail){/gray-fg}`;
418
- }
419
- if (note) {
420
- content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
421
- }
422
- box.setContent(content);
423
- }
424
- // 渲染 Daily Detail 视图(某一天的详细数据,按 project 分组显示所有 model 用量)
425
- function renderDailyDetail(box, data, date, scrollOffset = 0, width, pageSize) {
426
- const { dailySummary, dailyData } = data;
427
- const daySummary = dailySummary[date];
428
- const dayData = dailyData[date];
429
- if (!daySummary || !dayData) {
430
- box.setContent(`{bold}${date}{/bold}\n\nNo data available for this date.`);
431
- return;
432
- }
433
- const projectDetails = [];
434
- for (const [projectName, models] of Object.entries(dayData)) {
435
- const shortName = resolveProjectName(projectName, data.workspaceMappings);
436
- const modelList = [];
437
- let totalCost = 0;
438
- let totalTokens = 0;
439
- let totalRequests = 0;
440
- for (const [modelId, stats] of Object.entries(models)) {
441
- const s = stats;
442
- const cost = Number(s.cost ?? 0);
443
- const tokens = Number(s.totalTokens ?? 0);
444
- const requests = Number(s.requests ?? 0);
445
- modelList.push({ id: modelId, cost, tokens, requests });
446
- totalCost += cost;
447
- totalTokens += tokens;
448
- totalRequests += requests;
449
- }
450
- // 按 cost 降序排序 models,cost 相同时按 tokens 降序
451
- modelList.sort((a, b) => {
452
- const costDiff = b.cost - a.cost;
453
- if (Math.abs(costDiff) > 0.001)
454
- return costDiff;
455
- return b.tokens - a.tokens;
456
- });
457
- projectDetails.push({
458
- name: projectName,
459
- shortName,
460
- totalCost,
461
- totalTokens,
462
- totalRequests,
463
- models: modelList,
464
- });
465
- }
466
- // 按 project 总 cost 降序排序
467
- projectDetails.sort((a, b) => b.totalCost - a.totalCost);
468
- const displayLines = [];
469
- for (const project of projectDetails) {
470
- displayLines.push({ type: 'project', project });
471
- for (const model of project.models) {
472
- displayLines.push({ type: 'model', model });
473
- }
474
- }
475
- // 根据宽度计算列宽
476
- const availableWidth = width - 6; // padding
477
- const fixedCols = 12 + 12 + 12; // Cost + Requests + Tokens
478
- const nameCol = Math.max(25, availableWidth - fixedCols);
479
- const totalWidth = nameCol + fixedCols;
480
- let content = `{bold}${date} - Project & Model Usage Details{/bold}\n\n`;
481
- // 当天汇总
482
- content += `{green-fg}Total cost:{/green-fg} ${formatCost(daySummary.cost)} `;
483
- content += `{green-fg}Tokens:{/green-fg} ${formatTokens(daySummary.tokens)} `;
484
- content += `{green-fg}Requests:{/green-fg} ${formatNumber(daySummary.requests)} `;
485
- content += `{green-fg}Projects:{/green-fg} ${projectDetails.length}\n\n`;
486
- content +=
487
- '{underline}' +
488
- 'Project / Model'.padEnd(nameCol) +
489
- '~Cost'.padStart(12) +
490
- 'Requests'.padStart(12) +
491
- 'Tokens'.padStart(12) +
492
- '{/underline}\n';
493
- const safePageSize = Math.max(1, Math.floor(pageSize || 1));
494
- const visibleLines = displayLines.slice(scrollOffset, scrollOffset + safePageSize);
495
- for (const line of visibleLines) {
496
- if (line.type === 'project') {
497
- const p = line.project;
498
- content +=
499
- '{cyan-fg}' +
500
- truncate(p.shortName, nameCol - 1).padEnd(nameCol) +
501
- formatCost(p.totalCost).padStart(12) +
502
- formatNumber(p.totalRequests).padStart(12) +
503
- formatTokens(p.totalTokens).padStart(12) +
504
- '{/cyan-fg}\n';
505
- }
506
- else {
507
- const m = line.model;
508
- content +=
509
- (' ' + truncate(m.id, nameCol - 3)).padEnd(nameCol) +
510
- formatCost(m.cost).padStart(12) +
511
- formatNumber(m.requests).padStart(12) +
512
- formatTokens(m.tokens).padStart(12) +
513
- '\n';
514
- }
515
- }
516
- content += '─'.repeat(totalWidth) + '\n';
517
- content +=
518
- '{bold}' +
519
- `Total (${projectDetails.length} projects)`.padEnd(nameCol) +
520
- formatCost(daySummary.cost).padStart(12) +
521
- formatNumber(daySummary.requests).padStart(12) +
522
- formatTokens(daySummary.tokens).padStart(12) +
523
- '{/bold}\n';
524
- if (displayLines.length > safePageSize) {
525
- content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + safePageSize, displayLines.length)} of ${displayLines.length} rows (↑↓ scroll, Esc back){/gray-fg}`;
52
+ function getSortedMonthsFromDailyData(dailyData) {
53
+ const months = new Set();
54
+ for (const date of Object.keys(dailyData)) {
55
+ const month = date.slice(0, 7);
56
+ if (month)
57
+ months.add(month);
526
58
  }
527
- else {
528
- content += `\n{gray-fg}(Esc back to Daily list){/gray-fg}`;
529
- }
530
- box.setContent(content);
59
+ return [...months].sort().reverse();
531
60
  }
532
61
  // 纯文本输出模式
533
62
  function printTextReport(data) {
@@ -566,8 +95,8 @@ function printTextReport(data) {
566
95
  async function main() {
567
96
  const options = parseArgs();
568
97
  console.log('Loading data...');
569
- let currentSource = 'code';
570
- let data = await loadUsageData({ days: options.days, source: currentSource });
98
+ const initialSource = 'code';
99
+ const data = await loadUsageData({ days: options.days, source: initialSource });
571
100
  if (options.noTui) {
572
101
  printTextReport(data);
573
102
  return;
@@ -580,18 +109,8 @@ async function main() {
580
109
  fullUnicode: true,
581
110
  });
582
111
  // Tab 状态
583
- const tabs = ['Overview', 'By Model', 'By Project', 'Daily'];
584
- let currentTab = 0;
585
- let modelScrollOffset = 0;
586
- let projectScrollOffset = 0;
587
- let dailyScrollOffset = 0;
588
- let dailySelectedIndex = 0;
589
- let dailyDetailDate = null; // 当前查看详情的日期,null 表示在列表视图
590
- let dailyDetailScrollOffset = 0;
591
- let modelPageSize = 10;
592
- let projectPageSize = 10;
593
- let dailyPageSize = 20;
594
- let dailyDetailPageSize = 10;
112
+ const tabs = ['Overview', 'By Model', 'By Project', 'Daily', 'Monthly'];
113
+ const state = createInitialState({ tabs, source: initialSource, data });
595
114
  // Tab 栏
596
115
  const tabBar = blessed.box({
597
116
  top: 0,
@@ -644,7 +163,7 @@ async function main() {
644
163
  function updateTabBar() {
645
164
  let content = ' CodeBuddy Stats ';
646
165
  content += '{gray-fg}Source:{/gray-fg} ';
647
- if (currentSource === 'code') {
166
+ if (state.currentSource === 'code') {
648
167
  content += '{black-fg}{green-bg} Code {/green-bg}{/black-fg} ';
649
168
  content += '{gray-fg}IDE{/gray-fg} ';
650
169
  }
@@ -653,12 +172,14 @@ async function main() {
653
172
  content += '{black-fg}{green-bg} IDE {/green-bg}{/black-fg} ';
654
173
  }
655
174
  content += '{gray-fg}Views:{/gray-fg} ';
656
- for (let i = 0; i < tabs.length; i++) {
657
- if (i === currentTab) {
658
- content += `{black-fg}{green-bg} ${tabs[i]} {/green-bg}{/black-fg} `;
175
+ for (let i = 0; i < state.tabs.length; i++) {
176
+ if (i > 0)
177
+ content += '{gray-fg}|{/gray-fg} ';
178
+ if (i === state.currentTab) {
179
+ content += `{black-fg}{green-bg} ${state.tabs[i]} {/green-bg}{/black-fg} `;
659
180
  }
660
181
  else {
661
- content += `{gray-fg}${tabs[i]}{/gray-fg} `;
182
+ content += `{gray-fg}${state.tabs[i]}{/gray-fg} `;
662
183
  }
663
184
  }
664
185
  content += ' {gray-fg}(Tab view, s source){/gray-fg}';
@@ -667,8 +188,8 @@ async function main() {
667
188
  // 更新内容
668
189
  function updateContent() {
669
190
  const width = Number(screen.width) || 80;
670
- const note = currentSource === 'code'
671
- ? `针对 CodeBuddy Code < 2.20.0 版本产生的数据,由于没有请求级别的 model ID,用量是基于当前 CodeBuddy Code 设置的 model ID(${data.defaultModelId})计算价格的`
191
+ const note = state.currentSource === 'code'
192
+ ? `针对 CodeBuddy Code < 2.20.0 版本产生的数据,由于没有请求级别的 model ID,用量是基于当前 CodeBuddy Code 设置的 model ID(${state.data.defaultModelId})计算价格的`
672
193
  : 'IDE 的 usage 不包含缓存命中/写入 tokens,无法计算缓存相关价格与命中率;成本按 input/output tokens 估算';
673
194
  const screenHeight = Number(screen.height) || 24;
674
195
  const contentBoxHeight = Math.max(1, screenHeight - 5); // 对应 contentBox: height = '100%-5'
@@ -683,37 +204,79 @@ async function main() {
683
204
  const noteLines = note ? 2 + estimatedNoteLines : 0; // 两行空行 + 备注文本
684
205
  // By Model / By Project:表格尾部还有 total 两行
685
206
  const listReservedLines = baseLines + 2 + hintLines + noteLines + 1; // separator + total + safety
686
- modelPageSize = Math.max(1, innerHeight - listReservedLines);
687
- projectPageSize = Math.max(1, innerHeight - listReservedLines);
207
+ state.modelPageSize = Math.max(1, innerHeight - listReservedLines);
208
+ state.projectPageSize = Math.max(1, innerHeight - listReservedLines);
688
209
  // Daily:无 total 行
689
210
  const dailyReservedLines = baseLines + hintLines + noteLines + 1; // safety
690
- dailyPageSize = Math.max(1, innerHeight - dailyReservedLines);
211
+ state.dailyPageSize = Math.max(1, innerHeight - dailyReservedLines);
212
+ // Monthly:无 total 行
213
+ const monthlyReservedLines = baseLines + hintLines + noteLines + 1; // safety
214
+ state.monthlyPageSize = Math.max(1, innerHeight - monthlyReservedLines);
215
+ // Monthly Detail:有 summary 行
216
+ const monthlyDetailReservedLines = baseLines + 3 + hintLines + 1; // summary(2) + blank + safety
217
+ state.monthlyDetailPageSize = Math.max(1, innerHeight - monthlyDetailReservedLines);
691
218
  // Daily Detail:有 summary + total 行
692
219
  const dailyDetailReservedLines = baseLines + 3 + 2 + hintLines + 1; // summary(3) + separator + total + safety
693
- dailyDetailPageSize = Math.max(1, innerHeight - dailyDetailReservedLines);
694
- const modelMaxOffset = Math.max(0, Object.keys(data.modelTotals).length - modelPageSize);
695
- modelScrollOffset = Math.min(modelScrollOffset, modelMaxOffset);
696
- const projectMaxOffset = Math.max(0, Object.keys(data.projectTotals).length - projectPageSize);
697
- projectScrollOffset = Math.min(projectScrollOffset, projectMaxOffset);
698
- const dailyMaxOffset = Math.max(0, Object.keys(data.dailySummary).length - dailyPageSize);
699
- dailyScrollOffset = Math.min(dailyScrollOffset, dailyMaxOffset);
700
- dailySelectedIndex = Math.min(dailySelectedIndex, Math.max(0, Object.keys(data.dailySummary).length - 1));
701
- switch (currentTab) {
220
+ state.dailyDetailPageSize = Math.max(1, innerHeight - dailyDetailReservedLines);
221
+ const modelMaxOffset = Math.max(0, Object.keys(state.data.modelTotals).length - state.modelPageSize);
222
+ state.modelScrollOffset = Math.min(state.modelScrollOffset, modelMaxOffset);
223
+ const projectMaxOffset = Math.max(0, Object.keys(state.data.projectTotals).length - state.projectPageSize);
224
+ state.projectScrollOffset = Math.min(state.projectScrollOffset, projectMaxOffset);
225
+ const dailyMaxOffset = Math.max(0, Object.keys(state.data.dailySummary).length - state.dailyPageSize);
226
+ state.dailyScrollOffset = Math.min(state.dailyScrollOffset, dailyMaxOffset);
227
+ state.dailySelectedIndex = Math.min(state.dailySelectedIndex, Math.max(0, Object.keys(state.data.dailySummary).length - 1));
228
+ const sortedMonths = getSortedMonthsFromDailyData(state.data.dailyData);
229
+ // 如果当前在 Monthly detail 但该月份已不存在,回到列表
230
+ if (state.monthlyDetailMonth && !sortedMonths.includes(state.monthlyDetailMonth)) {
231
+ state.monthlyDetailMonth = null;
232
+ state.monthlyDetailScrollOffset = 0;
233
+ }
234
+ const monthlyMaxOffset = Math.max(0, sortedMonths.length - state.monthlyPageSize);
235
+ state.monthlyScrollOffset = Math.min(state.monthlyScrollOffset, monthlyMaxOffset);
236
+ state.monthlySelectedIndex = Math.min(state.monthlySelectedIndex, Math.max(0, sortedMonths.length - 1));
237
+ // 确保选中项在当前页内
238
+ if (state.monthlySelectedIndex < state.monthlyScrollOffset) {
239
+ state.monthlyScrollOffset = state.monthlySelectedIndex;
240
+ }
241
+ else if (state.monthlySelectedIndex >= state.monthlyScrollOffset + state.monthlyPageSize) {
242
+ state.monthlyScrollOffset = Math.max(0, state.monthlySelectedIndex - state.monthlyPageSize + 1);
243
+ }
244
+ // Monthly detail 的滚动上限
245
+ if (state.monthlyDetailMonth) {
246
+ const projects = new Set();
247
+ for (const [date, dayData] of Object.entries(state.data.dailyData)) {
248
+ if (!date.startsWith(state.monthlyDetailMonth))
249
+ continue;
250
+ for (const projectName of Object.keys(dayData ?? {}))
251
+ projects.add(projectName);
252
+ }
253
+ const maxOffset = Math.max(0, projects.size - state.monthlyDetailPageSize);
254
+ state.monthlyDetailScrollOffset = Math.min(state.monthlyDetailScrollOffset, maxOffset);
255
+ }
256
+ switch (state.currentTab) {
702
257
  case 0:
703
- renderOverview(contentBox, data, width, innerHeight, note);
258
+ renderOverview(contentBox, state.data, width, innerHeight, note);
704
259
  break;
705
260
  case 1:
706
- renderByModel(contentBox, data, modelScrollOffset, width, note, modelPageSize);
261
+ renderByModel(contentBox, state.data, state.modelScrollOffset, width, note, state.modelPageSize);
707
262
  break;
708
263
  case 2:
709
- renderByProject(contentBox, data, projectScrollOffset, width, note, projectPageSize);
264
+ renderByProject(contentBox, state.data, state.projectScrollOffset, width, note, state.projectPageSize);
710
265
  break;
711
266
  case 3:
712
- if (dailyDetailDate) {
713
- renderDailyDetail(contentBox, data, dailyDetailDate, dailyDetailScrollOffset, width, dailyDetailPageSize);
267
+ if (state.dailyDetailDate) {
268
+ renderDailyDetail(contentBox, state.data, state.dailyDetailDate, state.dailyDetailScrollOffset, width, state.dailyDetailPageSize);
269
+ }
270
+ else {
271
+ renderDaily(contentBox, state.data, state.dailyScrollOffset, state.dailySelectedIndex, width, note, state.dailyPageSize);
272
+ }
273
+ break;
274
+ case 4:
275
+ if (state.monthlyDetailMonth) {
276
+ renderMonthlyDetail(contentBox, state.data, state.monthlyDetailMonth, state.monthlyDetailScrollOffset, width, state.monthlyDetailPageSize);
714
277
  }
715
278
  else {
716
- renderDaily(contentBox, data, dailyScrollOffset, dailySelectedIndex, width, note, dailyPageSize);
279
+ renderMonthly(contentBox, state.data, state.monthlyScrollOffset, state.monthlySelectedIndex, width, note, state.monthlyPageSize);
717
280
  }
718
281
  break;
719
282
  }
@@ -721,17 +284,17 @@ async function main() {
721
284
  // 更新状态栏
722
285
  function updateStatusBar() {
723
286
  const daysInfo = options.days ? `Last ${options.days} days` : 'All time';
724
- const sourceInfo = currentSource === 'code' ? 'Code' : 'IDE';
287
+ const sourceInfo = state.currentSource === 'code' ? 'Code' : 'IDE';
725
288
  const rightContent = `v${VERSION}`;
726
289
  const width = Number(screen.width) || 80;
727
290
  // 根据剩余宽度决定左侧内容详细程度(预留版本号空间)
728
291
  const reservedForRight = rightContent.length + 2; // 版本号 + 两侧空格
729
292
  const availableForLeft = width - reservedForRight;
730
293
  let leftContent;
731
- const fullContent = ` ${daysInfo} | Source: ${sourceInfo} | Total: ${formatCost(data.grandTotal.cost)} | q quit, Tab view, s source, r refresh`;
732
- const mediumContent = ` ${daysInfo} | ${sourceInfo} | ${formatCost(data.grandTotal.cost)} | q/Tab/s/r`;
733
- const shortContent = ` ${sourceInfo} | ${formatCost(data.grandTotal.cost)} | q/Tab/s/r`;
734
- const minContent = ` ${formatCost(data.grandTotal.cost)}`;
294
+ const fullContent = ` ${daysInfo} | Source: ${sourceInfo} | Total: ${formatCost(state.data.grandTotal.cost)} | q quit, Tab view, s source, r refresh`;
295
+ const mediumContent = ` ${daysInfo} | ${sourceInfo} | ${formatCost(state.data.grandTotal.cost)} | q/Tab/s/r`;
296
+ const shortContent = ` ${sourceInfo} | ${formatCost(state.data.grandTotal.cost)} | q/Tab/s/r`;
297
+ const minContent = ` ${formatCost(state.data.grandTotal.cost)}`;
735
298
  if (fullContent.length <= availableForLeft) {
736
299
  leftContent = fullContent;
737
300
  }
@@ -748,187 +311,16 @@ async function main() {
748
311
  statusBar.setContent(leftContent + ' '.repeat(padding) + rightContent);
749
312
  }
750
313
  // 键盘事件
751
- screen.key(['tab'], () => {
752
- if (dailyDetailDate)
753
- return; // 在 detail 视图时禁用 tab 切换
754
- currentTab = (currentTab + 1) % tabs.length;
755
- modelScrollOffset = 0;
756
- projectScrollOffset = 0;
757
- dailyScrollOffset = 0;
758
- dailySelectedIndex = 0;
759
- contentBox.scrollTo(0);
760
- updateTabBar();
761
- updateContent();
762
- screen.render();
763
- });
764
- screen.key(['S-tab'], () => {
765
- if (dailyDetailDate)
766
- return; // 在 detail 视图时禁用 tab 切换
767
- currentTab = (currentTab - 1 + tabs.length) % tabs.length;
768
- modelScrollOffset = 0;
769
- projectScrollOffset = 0;
770
- dailyScrollOffset = 0;
771
- dailySelectedIndex = 0;
772
- contentBox.scrollTo(0);
773
- updateTabBar();
774
- updateContent();
775
- screen.render();
776
- });
777
- screen.key(['up', 'k'], () => {
778
- if (currentTab === 1) {
779
- modelScrollOffset = Math.max(0, modelScrollOffset - 1);
780
- updateContent();
781
- screen.render();
782
- return;
783
- }
784
- if (currentTab === 2) {
785
- projectScrollOffset = Math.max(0, projectScrollOffset - 1);
786
- updateContent();
787
- screen.render();
788
- return;
789
- }
790
- if (currentTab === 3) {
791
- if (dailyDetailDate) {
792
- // 在 detail 视图中滚动
793
- dailyDetailScrollOffset = Math.max(0, dailyDetailScrollOffset - 1);
794
- }
795
- else {
796
- // 在列表视图中移动选中项
797
- if (dailySelectedIndex > 0) {
798
- dailySelectedIndex--;
799
- // 如果选中项在当前页之上,滚动页面
800
- if (dailySelectedIndex < dailyScrollOffset) {
801
- dailyScrollOffset = dailySelectedIndex;
802
- }
803
- }
804
- }
805
- updateContent();
806
- screen.render();
807
- return;
808
- }
809
- contentBox.scroll(-1);
810
- screen.render();
811
- });
812
- screen.key(['down', 'j'], () => {
813
- if (currentTab === 1) {
814
- const maxOffset = Math.max(0, Object.keys(data.modelTotals).length - modelPageSize);
815
- modelScrollOffset = Math.min(maxOffset, modelScrollOffset + 1);
816
- updateContent();
817
- screen.render();
818
- return;
819
- }
820
- if (currentTab === 2) {
821
- const maxOffset = Math.max(0, Object.keys(data.projectTotals).length - projectPageSize);
822
- projectScrollOffset = Math.min(maxOffset, projectScrollOffset + 1);
823
- updateContent();
824
- screen.render();
825
- return;
826
- }
827
- if (currentTab === 3) {
828
- if (dailyDetailDate) {
829
- // 在 detail 视图中滚动(计算总行数:project 数 + 每个 project 下的 model 数)
830
- const dayData = data.dailyData[dailyDetailDate];
831
- if (dayData) {
832
- let totalLines = 0;
833
- for (const models of Object.values(dayData)) {
834
- totalLines += 1 + Object.keys(models).length; // 1 for project header + model count
835
- }
836
- const maxOffset = Math.max(0, totalLines - dailyDetailPageSize);
837
- dailyDetailScrollOffset = Math.min(maxOffset, dailyDetailScrollOffset + 1);
838
- }
839
- }
840
- else {
841
- // 在列表视图中移动选中项
842
- const totalDays = Object.keys(data.dailySummary).length;
843
- if (dailySelectedIndex < totalDays - 1) {
844
- dailySelectedIndex++;
845
- // 如果选中项超出当前页,滚动页面
846
- if (dailySelectedIndex >= dailyScrollOffset + dailyPageSize) {
847
- dailyScrollOffset = dailySelectedIndex - dailyPageSize + 1;
848
- }
849
- }
850
- }
851
- updateContent();
852
- screen.render();
853
- return;
854
- }
855
- contentBox.scroll(1);
856
- screen.render();
857
- });
858
- screen.key(['enter'], () => {
859
- if (currentTab === 3 && !dailyDetailDate) {
860
- // 进入 detail 视图
861
- const sortedDates = Object.keys(data.dailySummary).sort().reverse();
862
- if (sortedDates[dailySelectedIndex]) {
863
- dailyDetailDate = sortedDates[dailySelectedIndex];
864
- dailyDetailScrollOffset = 0;
865
- updateContent();
866
- screen.render();
867
- }
868
- }
869
- });
870
- screen.key(['escape', 'backspace'], () => {
871
- if (currentTab === 3 && dailyDetailDate) {
872
- // 返回列表视图
873
- dailyDetailDate = null;
874
- dailyDetailScrollOffset = 0;
875
- updateContent();
876
- screen.render();
877
- }
878
- });
879
- screen.key(['q', 'C-c'], () => {
880
- screen.destroy();
881
- process.exit(0);
882
- });
883
- screen.key(['r'], async () => {
884
- statusBar.setContent(' {yellow-fg}Reloading...{/yellow-fg}');
885
- screen.render();
886
- try {
887
- const prevDetailDate = dailyDetailDate;
888
- data = await loadUsageData({ days: options.days, source: currentSource });
889
- modelScrollOffset = 0;
890
- projectScrollOffset = 0;
891
- dailyScrollOffset = 0;
892
- dailySelectedIndex = 0;
893
- dailyDetailScrollOffset = 0;
894
- // 如果之前在详情视图且该日期仍存在,保持在详情视图
895
- if (prevDetailDate && data.dailySummary[prevDetailDate]) {
896
- dailyDetailDate = prevDetailDate;
897
- }
898
- else {
899
- dailyDetailDate = null;
900
- }
901
- contentBox.scrollTo(0);
902
- updateTabBar();
903
- updateContent();
904
- updateStatusBar();
905
- }
906
- catch (err) {
907
- statusBar.setContent(` {red-fg}Reload failed: ${String(err)}{/red-fg}`);
908
- }
909
- screen.render();
910
- });
911
- screen.key(['s'], async () => {
912
- statusBar.setContent(' {yellow-fg}Switching source...{/yellow-fg}');
913
- screen.render();
914
- try {
915
- currentSource = currentSource === 'code' ? 'ide' : 'code';
916
- data = await loadUsageData({ days: options.days, source: currentSource });
917
- modelScrollOffset = 0;
918
- projectScrollOffset = 0;
919
- dailyScrollOffset = 0;
920
- dailySelectedIndex = 0;
921
- dailyDetailDate = null;
922
- dailyDetailScrollOffset = 0;
923
- contentBox.scrollTo(0);
924
- updateTabBar();
925
- updateContent();
926
- updateStatusBar();
927
- }
928
- catch (err) {
929
- statusBar.setContent(` {red-fg}Switch source failed: ${String(err)}{/red-fg}`);
930
- }
931
- screen.render();
314
+ registerAppKeybindings({
315
+ screen,
316
+ contentBox,
317
+ statusBar,
318
+ options,
319
+ state,
320
+ loadUsageData: ({ days, source }) => loadUsageData({ days, source }),
321
+ updateTabBar,
322
+ updateContent,
323
+ updateStatusBar,
932
324
  });
933
325
  // 监听窗口大小变化
934
326
  screen.on('resize', () => {