codebuddy-stats 1.1.5 → 1.2.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/dist/index.js CHANGED
@@ -26,9 +26,9 @@ function parseArgs() {
26
26
  }
27
27
  else if (args[i] === '--help' || args[i] === '-h') {
28
28
  console.log(`
29
- CodeBuddy Cost Analyzer
29
+ CodeBuddy Stats
30
30
 
31
- Usage: cost-analyzer [options]
31
+ Usage: codebuddy-stats [options]
32
32
 
33
33
  Options:
34
34
  --days <n> 只显示最近 n 天的数据
@@ -54,129 +54,210 @@ function getHeatChar(cost, maxCost) {
54
54
  return '█';
55
55
  }
56
56
  // 渲染 Overview 视图
57
- function renderOverview(box, data, width, note) {
57
+ function renderOverview(box, data, width, height, note) {
58
58
  const { dailySummary, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data;
59
- // 根据宽度计算热力图周数
60
- const availableWidth = width - 10;
61
- const maxWeeks = Math.min(Math.floor(availableWidth / 2), 26); // 最多 26 周 (半年)
62
- let content = '{bold}Cost Heatmap{/bold}\n\n';
63
- // 生成正确的日期网格 - 从今天往前推算
64
- const today = new Date();
65
- const todayStr = today.toISOString().split('T')[0];
66
- // 找到最近的周六作为结束点(或今天)
67
- const endDate = new Date(today);
68
- // 往前推 maxWeeks 周
69
- const startDate = new Date(endDate);
70
- startDate.setDate(startDate.getDate() - maxWeeks * 7 + 1);
71
- // 调整到周日开始
72
- startDate.setDate(startDate.getDate() - startDate.getDay());
73
- // 构建周数组,每周从周日到周六
74
- const weeks = [];
75
- const currentDate = new Date(startDate);
76
- while (currentDate <= endDate) {
77
- const week = [];
78
- for (let d = 0; d < 7; d++) {
79
- const dateStr = currentDate.toISOString().split('T')[0];
80
- week.push(dateStr);
81
- currentDate.setDate(currentDate.getDate() + 1);
82
- }
83
- weeks.push(week);
84
- }
85
- // 以“当前热力图窗口”的最大值做归一化(避免历史极值导致近期全是浅色)
86
- const visibleCosts = [];
87
- for (const week of weeks) {
88
- for (const date of week) {
89
- if (!date || date > todayStr)
90
- continue;
91
- visibleCosts.push(dailySummary[date]?.cost ?? 0);
92
- }
93
- }
94
- const maxCost = Math.max(...visibleCosts, 0) || 1;
95
- // 月份标尺(在列上方标注月份变化)
96
59
  const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
97
- const colWidth = 2; // 每周一列:字符 + 空格
98
- const heatStartCol = 4; // 左侧周几标签宽度
99
- const headerLen = heatStartCol + weeks.length * colWidth;
100
- const monthHeader = Array.from({ length: headerLen }, () => ' ');
101
- let lastMonth = -1;
102
- let lastPlacedAt = -999;
103
- for (let i = 0; i < weeks.length; i++) {
104
- const week = weeks[i];
105
- const repDate = week.find(d => d && d <= todayStr) ?? week[0];
106
- if (!repDate)
107
- continue;
108
- const m = new Date(repDate).getMonth();
109
- if (m !== lastMonth) {
110
- const label = monthNames[m];
111
- const pos = heatStartCol + i * colWidth;
112
- // 避免月份标签过于拥挤/相互覆盖
113
- if (pos - lastPlacedAt >= 4 && pos + label.length <= monthHeader.length) {
114
- for (let k = 0; k < label.length; k++)
115
- monthHeader[pos + k] = label[k];
116
- lastPlacedAt = pos;
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);
117
107
  }
118
- lastMonth = m;
108
+ weeks.push(week);
119
109
  }
120
- }
121
- content += `{gray-fg}${monthHeader.join('').trimEnd()}{/gray-fg}\n`;
122
- const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
123
- for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
124
- let row = dayLabels[dayOfWeek].padEnd(4);
110
+ // 以“当前热力图窗口”的最大值做归一化(避免历史极值导致近期全是浅色)
111
+ const visibleCosts = [];
125
112
  for (const week of weeks) {
126
- const date = week[dayOfWeek];
127
- if (date && date <= todayStr && dailySummary[date]) {
128
- row += getHeatChar(dailySummary[date].cost, maxCost) + ' ';
113
+ for (const date of week) {
114
+ if (!date || date > todayStr)
115
+ continue;
116
+ visibleCosts.push(dailySummary[date]?.cost ?? 0);
129
117
  }
130
- else if (date && date <= todayStr) {
131
- row += '; // 有日期但无数据
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;
132
143
  }
133
- else {
134
- row += ' '; // 未来日期
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
+ }
135
162
  }
163
+ lines.push(row.trimEnd());
136
164
  }
137
- content += row + '\n';
138
- }
139
- const rangeStart = weeks[0]?.[0] ?? todayStr;
140
- content += `{gray-fg}Range: ${rangeStart} → ${todayStr}{/gray-fg}\n`;
141
- content += ' Less {gray-fg}·░▒▓{/gray-fg}{white-fg}█{/white-fg} More\n\n';
142
- // 汇总指标 - 根据宽度决定布局
143
- const avgDailyCost = activeDays > 0 ? grandTotal.cost / activeDays : 0;
144
- const summaryWidth = Math.min(width - 6, 70);
145
- content += '{bold}Summary{/bold}\n';
146
- content += '─'.repeat(summaryWidth) + '\n';
147
- if (width >= 80) {
148
- // 双列布局
149
- content += `{green-fg}~Total cost:{/green-fg} ${formatCost(grandTotal.cost).padStart(12)} `;
150
- content += `{green-fg}Active days:{/green-fg} ${String(activeDays).padStart(8)}\n`;
151
- content += `{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens).padStart(12)} `;
152
- content += `{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests).padStart(8)}\n`;
153
- content += `{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate).padStart(12)} `;
154
- content += `{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost).padStart(8)}\n\n`;
155
- }
156
- else {
157
- // 单列布局
158
- content += `{green-fg}~Total cost:{/green-fg} ${formatCost(grandTotal.cost)}\n`;
159
- content += `{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens)}\n`;
160
- content += `{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests)}\n`;
161
- content += `{green-fg}Active days:{/green-fg} ${activeDays}\n`;
162
- content += `{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate)}\n`;
163
- content += `{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost)}\n\n`;
164
- }
165
- if (topModel) {
166
- content += `{cyan-fg}Top model:{/cyan-fg} ${topModel.id} (${formatCost(topModel.cost)})\n`;
167
- }
168
- if (topProject) {
169
- const projectMaxLen = width >= 100 ? 60 : 35;
170
- const shortName = resolveProjectName(topProject.name, data.workspaceMappings);
171
- content += `{cyan-fg}Top project:{/cyan-fg} ${truncate(shortName, projectMaxLen)} (${formatCost(topProject.cost)})\n`;
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;
172
232
  }
173
- if (note) {
174
- content += `\n{gray-fg}备注:${note}{/gray-fg}\n`;
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
+ }
175
255
  }
176
- box.setContent(content);
256
+ // fallback:仍然输出纵向布局(可滚动)
257
+ box.setContent(verticalLines.join('\n'));
177
258
  }
178
259
  // 渲染 By Model 视图
179
- function renderByModel(box, data, width, note) {
260
+ function renderByModel(box, data, scrollOffset = 0, width, note, pageSize) {
180
261
  const { modelTotals, grandTotal } = data;
181
262
  const sorted = Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost);
182
263
  // 根据宽度计算列宽
@@ -193,7 +274,9 @@ function renderByModel(box, data, width, note) {
193
274
  'Tokens'.padStart(12) +
194
275
  'Avg/Req'.padStart(10) +
195
276
  '{/underline}\n';
196
- for (const [modelId, stats] of sorted) {
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) {
197
280
  const avgPerReq = stats.requests > 0 ? stats.cost / stats.requests : 0;
198
281
  content +=
199
282
  truncate(modelId, modelCol - 1).padEnd(modelCol) +
@@ -211,13 +294,16 @@ function renderByModel(box, data, width, note) {
211
294
  formatNumber(grandTotal.requests).padStart(12) +
212
295
  formatTokens(grandTotal.tokens).padStart(12) +
213
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
+ }
214
300
  if (note) {
215
- content += `\n{gray-fg}备注:${note}{/gray-fg}\n`;
301
+ content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
216
302
  }
217
303
  box.setContent(content);
218
304
  }
219
305
  // 渲染 By Project 视图
220
- function renderByProject(box, data, width, note) {
306
+ function renderByProject(box, data, scrollOffset = 0, width, note, pageSize) {
221
307
  const { projectTotals, grandTotal } = data;
222
308
  const sorted = Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost);
223
309
  // 根据宽度计算列宽
@@ -233,7 +319,9 @@ function renderByProject(box, data, width, note) {
233
319
  'Requests'.padStart(12) +
234
320
  'Tokens'.padStart(12) +
235
321
  '{/underline}\n';
236
- for (const [projectName, stats] of sorted) {
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) {
237
325
  // 简化项目名
238
326
  const shortName = resolveProjectName(projectName, data.workspaceMappings);
239
327
  content +=
@@ -251,13 +339,16 @@ function renderByProject(box, data, width, note) {
251
339
  formatNumber(grandTotal.requests).padStart(12) +
252
340
  formatTokens(grandTotal.tokens).padStart(12) +
253
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
+ }
254
345
  if (note) {
255
- content += `\n{gray-fg}备注:${note}{/gray-fg}\n`;
346
+ content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
256
347
  }
257
348
  box.setContent(content);
258
349
  }
259
350
  // 渲染 Daily 视图
260
- function renderDaily(box, data, scrollOffset = 0, width, note) {
351
+ function renderDaily(box, data, scrollOffset = 0, selectedIndex = 0, width, note, pageSize) {
261
352
  const { dailySummary, dailyData } = data;
262
353
  const sortedDates = Object.keys(dailySummary).sort().reverse();
263
354
  // 根据宽度计算列宽
@@ -280,8 +371,10 @@ function renderDaily(box, data, scrollOffset = 0, width, note) {
280
371
  'Top Model'.padStart(modelCol) +
281
372
  'Top Project'.padStart(projectCol) +
282
373
  '{/underline}\n';
283
- const visibleDates = sortedDates.slice(scrollOffset, scrollOffset + 20);
284
- for (const date of visibleDates) {
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];
285
378
  const daySummary = dailySummary[date];
286
379
  const dayData = dailyData[date];
287
380
  if (!daySummary || !dayData)
@@ -303,27 +396,101 @@ function renderDaily(box, data, scrollOffset = 0, width, note) {
303
396
  }
304
397
  }
305
398
  const shortProject = resolveProjectName(topProject.name, data.workspaceMappings);
306
- content +=
307
- date.padEnd(dateCol) +
308
- formatCost(daySummary.cost).padStart(costCol) +
309
- formatTokens(daySummary.tokens).padStart(tokensCol) +
310
- formatNumber(daySummary.requests).padStart(reqCol) +
311
- truncate(topModel.id, modelCol - 1).padStart(modelCol) +
312
- truncate(shortProject, projectCol - 1).padStart(projectCol) +
313
- '\n';
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
+ }
314
412
  }
315
- if (sortedDates.length > 20) {
316
- content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + 20, sortedDates.length)} of ${sortedDates.length} days (↑↓ to scroll){/gray-fg}`;
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}`;
317
418
  }
318
419
  if (note) {
319
420
  content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
320
421
  }
321
422
  box.setContent(content);
322
423
  }
424
+ // 渲染 Daily Detail 视图(某一天的详细数据)
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
+ // 汇总当天所有模型的用量
434
+ const modelStats = {};
435
+ for (const [, models] of Object.entries(dayData)) {
436
+ for (const [model, stats] of Object.entries(models)) {
437
+ const s = stats;
438
+ if (!modelStats[model]) {
439
+ modelStats[model] = { cost: 0, tokens: 0, requests: 0 };
440
+ }
441
+ modelStats[model].cost += Number(s.cost ?? 0);
442
+ modelStats[model].tokens += Number(s.totalTokens ?? 0);
443
+ modelStats[model].requests += Number(s.requests ?? 0);
444
+ }
445
+ }
446
+ const sortedModels = Object.entries(modelStats).sort((a, b) => b[1].cost - a[1].cost);
447
+ // 根据宽度计算列宽
448
+ const availableWidth = width - 6; // padding
449
+ const fixedCols = 12 + 12 + 12; // Cost + Requests + Tokens
450
+ const modelCol = Math.max(25, availableWidth - fixedCols);
451
+ const totalWidth = modelCol + fixedCols;
452
+ let content = `{bold}${date} - Model Usage Details{/bold}\n\n`;
453
+ // 当天汇总
454
+ content += `{green-fg}Total cost:{/green-fg} ${formatCost(daySummary.cost)} `;
455
+ content += `{green-fg}Tokens:{/green-fg} ${formatTokens(daySummary.tokens)} `;
456
+ content += `{green-fg}Requests:{/green-fg} ${formatNumber(daySummary.requests)}\n\n`;
457
+ content +=
458
+ '{underline}' +
459
+ 'Model'.padEnd(modelCol) +
460
+ '~Cost'.padStart(12) +
461
+ 'Requests'.padStart(12) +
462
+ 'Tokens'.padStart(12) +
463
+ '{/underline}\n';
464
+ const safePageSize = Math.max(1, Math.floor(pageSize || 1));
465
+ const visibleModels = sortedModels.slice(scrollOffset, scrollOffset + safePageSize);
466
+ for (const [modelId, stats] of visibleModels) {
467
+ content +=
468
+ truncate(modelId, modelCol - 1).padEnd(modelCol) +
469
+ formatCost(stats.cost).padStart(12) +
470
+ formatNumber(stats.requests).padStart(12) +
471
+ formatTokens(stats.tokens).padStart(12) +
472
+ '\n';
473
+ }
474
+ content += '─'.repeat(totalWidth) + '\n';
475
+ content +=
476
+ '{bold}' +
477
+ `Total (${sortedModels.length} models)`.padEnd(modelCol) +
478
+ formatCost(daySummary.cost).padStart(12) +
479
+ formatNumber(daySummary.requests).padStart(12) +
480
+ formatTokens(daySummary.tokens).padStart(12) +
481
+ '{/bold}\n';
482
+ if (sortedModels.length > safePageSize) {
483
+ content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + safePageSize, sortedModels.length)} of ${sortedModels.length} models (↑↓ scroll, Esc back){/gray-fg}`;
484
+ }
485
+ else {
486
+ content += `\n{gray-fg}(Esc back to Daily list){/gray-fg}`;
487
+ }
488
+ box.setContent(content);
489
+ }
323
490
  // 纯文本输出模式
324
491
  function printTextReport(data) {
325
492
  const { modelTotals, projectTotals, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data;
326
- console.log('\n🤖 CodeBuddy Cost Analysis Report');
493
+ console.log('\n🤖 CodeBuddy Stats Report');
327
494
  console.log('='.repeat(50));
328
495
  console.log(`\nTotal cost: ${formatCost(grandTotal.cost)}`);
329
496
  console.log(`Total tokens: ${formatTokens(grandTotal.tokens)}`);
@@ -366,14 +533,23 @@ async function main() {
366
533
  // 创建 TUI
367
534
  const screen = blessed.screen({
368
535
  smartCSR: true,
369
- title: 'CodeBuddy Cost Analyzer',
536
+ title: 'CodeBuddy Stats',
370
537
  forceUnicode: true,
371
538
  fullUnicode: true,
372
539
  });
373
540
  // Tab 状态
374
541
  const tabs = ['Overview', 'By Model', 'By Project', 'Daily'];
375
542
  let currentTab = 0;
543
+ let modelScrollOffset = 0;
544
+ let projectScrollOffset = 0;
376
545
  let dailyScrollOffset = 0;
546
+ let dailySelectedIndex = 0;
547
+ let dailyDetailDate = null; // 当前查看详情的日期,null 表示在列表视图
548
+ let dailyDetailScrollOffset = 0;
549
+ let modelPageSize = 10;
550
+ let projectPageSize = 10;
551
+ let dailyPageSize = 20;
552
+ let dailyDetailPageSize = 10;
377
553
  // Tab 栏
378
554
  const tabBar = blessed.box({
379
555
  top: 0,
@@ -424,7 +600,7 @@ async function main() {
424
600
  screen.append(statusBar);
425
601
  // 更新 Tab 栏
426
602
  function updateTabBar() {
427
- let content = ' Cost Analysis ';
603
+ let content = ' CodeBuddy Stats ';
428
604
  content += '{gray-fg}Source:{/gray-fg} ';
429
605
  if (currentSource === 'code') {
430
606
  content += '{black-fg}{green-bg} Code {/green-bg}{/black-fg} ';
@@ -452,18 +628,51 @@ async function main() {
452
628
  const note = currentSource === 'code'
453
629
  ? `针对 CodeBuddy Code < 2.20.0 版本产生的数据,由于没有请求级别的 model ID,用量是基于当前 CodeBuddy Code 设置的 model ID(${data.defaultModelId})计算价格的`
454
630
  : 'IDE 的 usage 不包含缓存命中/写入 tokens,无法计算缓存相关价格与命中率;成本按 input/output tokens 估算';
631
+ const screenHeight = Number(screen.height) || 24;
632
+ const contentBoxHeight = Math.max(1, screenHeight - 5); // 对应 contentBox: height = '100%-5'
633
+ const paddingTop = Number(contentBox.padding?.top ?? 0);
634
+ const paddingBottom = Number(contentBox.padding?.bottom ?? 0);
635
+ const innerHeight = Math.max(1, contentBoxHeight - paddingTop - paddingBottom);
636
+ // 根据当前可用高度动态调整每页行数(By Model / By Project / Daily),避免 resize 后内容溢出
637
+ const baseLines = 3; // title + blank + header
638
+ const hintLines = 2; // blank + hint line(最坏情况)
639
+ const availableTextWidth = Math.max(20, width - 8);
640
+ const estimatedNoteLines = note ? Math.max(1, Math.ceil(`备注:${note}`.length / availableTextWidth)) : 0;
641
+ const noteLines = note ? 2 + estimatedNoteLines : 0; // 两行空行 + 备注文本
642
+ // By Model / By Project:表格尾部还有 total 两行
643
+ const listReservedLines = baseLines + 2 + hintLines + noteLines + 1; // separator + total + safety
644
+ modelPageSize = Math.max(1, innerHeight - listReservedLines);
645
+ projectPageSize = Math.max(1, innerHeight - listReservedLines);
646
+ // Daily:无 total 行
647
+ const dailyReservedLines = baseLines + hintLines + noteLines + 1; // safety
648
+ dailyPageSize = Math.max(1, innerHeight - dailyReservedLines);
649
+ // Daily Detail:有 summary + total 行
650
+ const dailyDetailReservedLines = baseLines + 3 + 2 + hintLines + 1; // summary(3) + separator + total + safety
651
+ dailyDetailPageSize = Math.max(1, innerHeight - dailyDetailReservedLines);
652
+ const modelMaxOffset = Math.max(0, Object.keys(data.modelTotals).length - modelPageSize);
653
+ modelScrollOffset = Math.min(modelScrollOffset, modelMaxOffset);
654
+ const projectMaxOffset = Math.max(0, Object.keys(data.projectTotals).length - projectPageSize);
655
+ projectScrollOffset = Math.min(projectScrollOffset, projectMaxOffset);
656
+ const dailyMaxOffset = Math.max(0, Object.keys(data.dailySummary).length - dailyPageSize);
657
+ dailyScrollOffset = Math.min(dailyScrollOffset, dailyMaxOffset);
658
+ dailySelectedIndex = Math.min(dailySelectedIndex, Math.max(0, Object.keys(data.dailySummary).length - 1));
455
659
  switch (currentTab) {
456
660
  case 0:
457
- renderOverview(contentBox, data, width, note);
661
+ renderOverview(contentBox, data, width, innerHeight, note);
458
662
  break;
459
663
  case 1:
460
- renderByModel(contentBox, data, width, note);
664
+ renderByModel(contentBox, data, modelScrollOffset, width, note, modelPageSize);
461
665
  break;
462
666
  case 2:
463
- renderByProject(contentBox, data, width, note);
667
+ renderByProject(contentBox, data, projectScrollOffset, width, note, projectPageSize);
464
668
  break;
465
669
  case 3:
466
- renderDaily(contentBox, data, dailyScrollOffset, width, note);
670
+ if (dailyDetailDate) {
671
+ renderDailyDetail(contentBox, data, dailyDetailDate, dailyDetailScrollOffset, width, dailyDetailPageSize);
672
+ }
673
+ else {
674
+ renderDaily(contentBox, data, dailyScrollOffset, dailySelectedIndex, width, note, dailyPageSize);
675
+ }
467
676
  break;
468
677
  }
469
678
  }
@@ -498,30 +707,126 @@ async function main() {
498
707
  }
499
708
  // 键盘事件
500
709
  screen.key(['tab'], () => {
710
+ if (dailyDetailDate)
711
+ return; // 在 detail 视图时禁用 tab 切换
501
712
  currentTab = (currentTab + 1) % tabs.length;
713
+ modelScrollOffset = 0;
714
+ projectScrollOffset = 0;
502
715
  dailyScrollOffset = 0;
716
+ dailySelectedIndex = 0;
717
+ contentBox.scrollTo(0);
503
718
  updateTabBar();
504
719
  updateContent();
505
720
  screen.render();
506
721
  });
507
722
  screen.key(['S-tab'], () => {
723
+ if (dailyDetailDate)
724
+ return; // 在 detail 视图时禁用 tab 切换
508
725
  currentTab = (currentTab - 1 + tabs.length) % tabs.length;
726
+ modelScrollOffset = 0;
727
+ projectScrollOffset = 0;
509
728
  dailyScrollOffset = 0;
729
+ dailySelectedIndex = 0;
730
+ contentBox.scrollTo(0);
510
731
  updateTabBar();
511
732
  updateContent();
512
733
  screen.render();
513
734
  });
514
735
  screen.key(['up', 'k'], () => {
736
+ if (currentTab === 1) {
737
+ modelScrollOffset = Math.max(0, modelScrollOffset - 1);
738
+ updateContent();
739
+ screen.render();
740
+ return;
741
+ }
742
+ if (currentTab === 2) {
743
+ projectScrollOffset = Math.max(0, projectScrollOffset - 1);
744
+ updateContent();
745
+ screen.render();
746
+ return;
747
+ }
515
748
  if (currentTab === 3) {
516
- dailyScrollOffset = Math.max(0, dailyScrollOffset - 1);
749
+ if (dailyDetailDate) {
750
+ // 在 detail 视图中滚动
751
+ dailyDetailScrollOffset = Math.max(0, dailyDetailScrollOffset - 1);
752
+ }
753
+ else {
754
+ // 在列表视图中移动选中项
755
+ if (dailySelectedIndex > 0) {
756
+ dailySelectedIndex--;
757
+ // 如果选中项在当前页之上,滚动页面
758
+ if (dailySelectedIndex < dailyScrollOffset) {
759
+ dailyScrollOffset = dailySelectedIndex;
760
+ }
761
+ }
762
+ }
517
763
  updateContent();
518
764
  screen.render();
765
+ return;
519
766
  }
767
+ contentBox.scroll(-1);
768
+ screen.render();
520
769
  });
521
770
  screen.key(['down', 'j'], () => {
771
+ if (currentTab === 1) {
772
+ const maxOffset = Math.max(0, Object.keys(data.modelTotals).length - modelPageSize);
773
+ modelScrollOffset = Math.min(maxOffset, modelScrollOffset + 1);
774
+ updateContent();
775
+ screen.render();
776
+ return;
777
+ }
778
+ if (currentTab === 2) {
779
+ const maxOffset = Math.max(0, Object.keys(data.projectTotals).length - projectPageSize);
780
+ projectScrollOffset = Math.min(maxOffset, projectScrollOffset + 1);
781
+ updateContent();
782
+ screen.render();
783
+ return;
784
+ }
522
785
  if (currentTab === 3) {
523
- const maxOffset = Math.max(0, Object.keys(data.dailySummary).length - 20);
524
- dailyScrollOffset = Math.min(maxOffset, dailyScrollOffset + 1);
786
+ if (dailyDetailDate) {
787
+ // detail 视图中滚动(需要计算当天的模型数量)
788
+ const dayData = data.dailyData[dailyDetailDate];
789
+ if (dayData) {
790
+ const modelCount = new Set(Object.values(dayData).flatMap(models => Object.keys(models))).size;
791
+ const maxOffset = Math.max(0, modelCount - dailyDetailPageSize);
792
+ dailyDetailScrollOffset = Math.min(maxOffset, dailyDetailScrollOffset + 1);
793
+ }
794
+ }
795
+ else {
796
+ // 在列表视图中移动选中项
797
+ const totalDays = Object.keys(data.dailySummary).length;
798
+ if (dailySelectedIndex < totalDays - 1) {
799
+ dailySelectedIndex++;
800
+ // 如果选中项超出当前页,滚动页面
801
+ if (dailySelectedIndex >= dailyScrollOffset + dailyPageSize) {
802
+ dailyScrollOffset = dailySelectedIndex - dailyPageSize + 1;
803
+ }
804
+ }
805
+ }
806
+ updateContent();
807
+ screen.render();
808
+ return;
809
+ }
810
+ contentBox.scroll(1);
811
+ screen.render();
812
+ });
813
+ screen.key(['enter'], () => {
814
+ if (currentTab === 3 && !dailyDetailDate) {
815
+ // 进入 detail 视图
816
+ const sortedDates = Object.keys(data.dailySummary).sort().reverse();
817
+ if (sortedDates[dailySelectedIndex]) {
818
+ dailyDetailDate = sortedDates[dailySelectedIndex];
819
+ dailyDetailScrollOffset = 0;
820
+ updateContent();
821
+ screen.render();
822
+ }
823
+ }
824
+ });
825
+ screen.key(['escape', 'backspace'], () => {
826
+ if (currentTab === 3 && dailyDetailDate) {
827
+ // 返回列表视图
828
+ dailyDetailDate = null;
829
+ dailyDetailScrollOffset = 0;
525
830
  updateContent();
526
831
  screen.render();
527
832
  }
@@ -535,7 +840,13 @@ async function main() {
535
840
  screen.render();
536
841
  try {
537
842
  data = await loadUsageData({ days: options.days, source: currentSource });
843
+ modelScrollOffset = 0;
844
+ projectScrollOffset = 0;
538
845
  dailyScrollOffset = 0;
846
+ dailySelectedIndex = 0;
847
+ dailyDetailDate = null;
848
+ dailyDetailScrollOffset = 0;
849
+ contentBox.scrollTo(0);
539
850
  updateTabBar();
540
851
  updateContent();
541
852
  updateStatusBar();
@@ -551,7 +862,13 @@ async function main() {
551
862
  try {
552
863
  currentSource = currentSource === 'code' ? 'ide' : 'code';
553
864
  data = await loadUsageData({ days: options.days, source: currentSource });
865
+ modelScrollOffset = 0;
866
+ projectScrollOffset = 0;
554
867
  dailyScrollOffset = 0;
868
+ dailySelectedIndex = 0;
869
+ dailyDetailDate = null;
870
+ dailyDetailScrollOffset = 0;
871
+ contentBox.scrollTo(0);
555
872
  updateTabBar();
556
873
  updateContent();
557
874
  updateStatusBar();
@@ -480,8 +480,8 @@ function pathExistsSync(p) {
480
480
  * 尝试将 CodeBuddy Code 的项目名(路径中 / 替换为 -)还原为真实路径
481
481
  * 使用回溯搜索,因为目录名本身可能包含 -
482
482
  *
483
- * 例如: "Users-foo-Documents-project-codebudy-cost-analyzer"
484
- * -> "/Users/foo/Documents/project/codebudy-cost-analyzer"
483
+ * 例如: "Users-foo-Documents-project-codebuddy-stats"
484
+ * -> "/Users/foo/Documents/project/codebuddy-stats"
485
485
  */
486
486
  function tryResolveCodePath(name) {
487
487
  // 检查缓存
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebuddy-stats",
3
- "version": "1.1.5",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -37,7 +37,7 @@
37
37
  "author": "AnotiaWang",
38
38
  "license": "ISC",
39
39
  "packageManager": "pnpm@9.15.1",
40
- "description": "CodeBuddy AI cost analyzer with terminal UI",
40
+ "description": "CodeBuddy AI usage statistics with terminal UI",
41
41
  "dependencies": {
42
42
  "blessed": "^0.1.81"
43
43
  },