codebuddy-stats 1.2.12 → 1.3.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 +104 -712
- package/dist/tui/keybindings.js +293 -0
- package/dist/tui/state.js +24 -0
- package/dist/views/by-model.js +46 -0
- package/dist/views/by-project.js +46 -0
- package/dist/views/daily-detail.js +109 -0
- package/dist/views/daily.js +75 -0
- package/dist/views/monthly-detail.js +97 -0
- package/dist/views/monthly.js +101 -0
- package/dist/views/overview.js +216 -0
- package/package.json +1 -1
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
|
658
|
-
content +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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', () => {
|