shellwecount 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +30 -0
- package/shellwecount.js +516 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shellwecount",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A terminal-based command history timeline visualizer with color-coded categories, frequency ranking, and density heatmap. / 基于终端的命令历史时间线可视化工具,支持命令分类着色、使用频率排名和密度热力图。",
|
|
5
|
+
"main": "shellwecount.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shellwecount": "shellwecount.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"terminal",
|
|
14
|
+
"cli",
|
|
15
|
+
"history",
|
|
16
|
+
"timeline",
|
|
17
|
+
"visualization",
|
|
18
|
+
"shell",
|
|
19
|
+
"count",
|
|
20
|
+
"命令历史",
|
|
21
|
+
"时间线",
|
|
22
|
+
"可视化"
|
|
23
|
+
],
|
|
24
|
+
"author": "yangsta",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=14.0.0"
|
|
28
|
+
},
|
|
29
|
+
"preferGlobal": true
|
|
30
|
+
}
|
package/shellwecount.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
// ─── i18n ──────────────────────────────────────────────────────
|
|
10
|
+
const i18n = {
|
|
11
|
+
en: {
|
|
12
|
+
title: 'Command History Timeline',
|
|
13
|
+
totalCmds: 'Total',
|
|
14
|
+
cmdsLabel: 'commands',
|
|
15
|
+
topCommands: 'Top Commands',
|
|
16
|
+
times: 'times',
|
|
17
|
+
categories: 'Categories',
|
|
18
|
+
density: 'Density Heatmap',
|
|
19
|
+
legend: 'Legend',
|
|
20
|
+
low: 'Low',
|
|
21
|
+
medium: 'Med',
|
|
22
|
+
high: 'High',
|
|
23
|
+
extreme: 'Extreme',
|
|
24
|
+
cmdList: 'Command List',
|
|
25
|
+
noRecords: 'No commands found for this time period.',
|
|
26
|
+
using: 'Using',
|
|
27
|
+
errorNoHistory: 'Error: No history file found',
|
|
28
|
+
usage: `
|
|
29
|
+
Command History Timeline Visualizer
|
|
30
|
+
|
|
31
|
+
Usage: shellwecount [options]
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-r, --range <range> Time range (today|yesterday|week|month) [default: today]
|
|
35
|
+
-f, --format <format> Display format:
|
|
36
|
+
both - All views (default)
|
|
37
|
+
top - Top commands ranking
|
|
38
|
+
stats - Ranking + Category breakdown
|
|
39
|
+
density - Density heatmap
|
|
40
|
+
list - Command list
|
|
41
|
+
-t, --top <count> Show top N commands [default: 10]
|
|
42
|
+
-l, --lang <lang> Language (en|zh) [default: auto-detect]
|
|
43
|
+
-h, --help Show help
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
shellwecount --range today # Today's full view
|
|
47
|
+
shellwecount -r week -f top # This week's top 10
|
|
48
|
+
shellwecount -r month -t 20 -f top # This month's top 20
|
|
49
|
+
shellwecount -r week -f stats # This week's stats + ranking
|
|
50
|
+
`,
|
|
51
|
+
},
|
|
52
|
+
zh: {
|
|
53
|
+
title: '命令历史时间线',
|
|
54
|
+
totalCmds: '共',
|
|
55
|
+
cmdsLabel: '条命令',
|
|
56
|
+
topCommands: '常用命令 TOP',
|
|
57
|
+
times: '次',
|
|
58
|
+
categories: '命令分类',
|
|
59
|
+
density: '密度热力图',
|
|
60
|
+
legend: '图例',
|
|
61
|
+
low: '低',
|
|
62
|
+
medium: '中',
|
|
63
|
+
high: '高',
|
|
64
|
+
extreme: '极高',
|
|
65
|
+
cmdList: '命令列表',
|
|
66
|
+
noRecords: '该时间段内没有命令记录',
|
|
67
|
+
using: '使用',
|
|
68
|
+
errorNoHistory: '错误: 未找到历史文件',
|
|
69
|
+
usage: `
|
|
70
|
+
命令历史时间线可视化工具
|
|
71
|
+
|
|
72
|
+
用法: shellwecount [选项]
|
|
73
|
+
|
|
74
|
+
选项:
|
|
75
|
+
-r, --range <范围> 时间范围 (today|yesterday|week|month) [默认: today]
|
|
76
|
+
-f, --format <格式> 显示格式:
|
|
77
|
+
both - 全部 (默认)
|
|
78
|
+
top - 常用命令排名
|
|
79
|
+
stats - 排名 + 分类统计
|
|
80
|
+
density - 密度热力图
|
|
81
|
+
list - 命令列表
|
|
82
|
+
-t, --top <数量> 显示排名前N的命令 [默认: 10]
|
|
83
|
+
-l, --lang <语言> 语言 (en|zh) [默认: 自动检测]
|
|
84
|
+
-h, --help 显示帮助
|
|
85
|
+
|
|
86
|
+
示例:
|
|
87
|
+
shellwecount --range today # 今天的完整视图
|
|
88
|
+
shellwecount -r week -f top # 本周常用命令TOP10
|
|
89
|
+
shellwecount -r month -t 20 -f top # 本月TOP20
|
|
90
|
+
shellwecount -r week -f stats # 本周统计+排名
|
|
91
|
+
`,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function detectLang() {
|
|
96
|
+
const locale = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '';
|
|
97
|
+
return locale.includes('zh') || locale.includes('CN') ? 'zh' : 'en';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let lang = detectLang();
|
|
101
|
+
function t(key) { return i18n[lang]?.[key] || i18n.en[key] || key; }
|
|
102
|
+
|
|
103
|
+
// ─── ANSI Colors ───────────────────────────────────────────────
|
|
104
|
+
const colors = {
|
|
105
|
+
reset: '\x1b[0m',
|
|
106
|
+
bold: '\x1b[1m',
|
|
107
|
+
dim: '\x1b[2m',
|
|
108
|
+
cyan: '\x1b[36m',
|
|
109
|
+
green: '\x1b[32m',
|
|
110
|
+
yellow: '\x1b[33m',
|
|
111
|
+
blue: '\x1b[34m',
|
|
112
|
+
magenta: '\x1b[35m',
|
|
113
|
+
red: '\x1b[31m',
|
|
114
|
+
white: '\x1b[37m',
|
|
115
|
+
gray: '\x1b[90m',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// ─── Command Category Classifier ───────────────────────────────
|
|
119
|
+
const categories = [
|
|
120
|
+
{ name: 'git', patterns: [/^git\s/, /^gh\s/], color: colors.magenta },
|
|
121
|
+
{ name: 'npm', patterns: [/^npm\s/, /^yarn\s/, /^pnpm\s/, /^bun\s/], color: colors.red },
|
|
122
|
+
{ name: 'docker', patterns: [/^docker\s/, /^docker-compose\s/, /^kubectl\s/], color: colors.blue },
|
|
123
|
+
{ name: 'python', patterns: [/^python[3]?\s/, /^pip[3]?\s/, /^uv\s/], color: colors.yellow },
|
|
124
|
+
{ name: 'node', patterns: [/^node\s/, /^npx\s/, /^deno\s/], color: colors.green },
|
|
125
|
+
{ name: 'editor', patterns: [/^vim\s/, /^nvim\s/, /^nano\s/, /^code\s/, /^emacs\s/], color: colors.cyan },
|
|
126
|
+
{ name: 'build', patterns: [/^make\s/, /^cmake\s/, /^cargo\s/, /^go\s/], color: colors.yellow },
|
|
127
|
+
{ name: 'system', patterns: [/^sudo\s/, /^apt\s/, /^dnf\s/, /^pacman\s/, /^systemctl\s/, /^service\s/], color: colors.gray },
|
|
128
|
+
{ name: 'network', patterns: [/^curl\s/, /^wget\s/, /^ssh\s/, /^scp\s/, /^rsync\s/], color: colors.blue },
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
function classifyCommand(cmd) {
|
|
132
|
+
for (const cat of categories) {
|
|
133
|
+
for (const pattern of cat.patterns) {
|
|
134
|
+
if (pattern.test(cmd)) return cat;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { name: 'other', color: colors.white };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getCommandBase(cmd) {
|
|
141
|
+
return cmd.split(/\s+/)[0] || cmd;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── History File Detection & Parsing ─────────────────────────
|
|
145
|
+
function findHistoryFile() {
|
|
146
|
+
const shell = process.env.SHELL || '';
|
|
147
|
+
const home = os.homedir();
|
|
148
|
+
|
|
149
|
+
if (shell.includes('zsh')) {
|
|
150
|
+
const zshHist = path.join(home, '.zsh_history');
|
|
151
|
+
if (fs.existsSync(zshHist)) return { path: zshHist, type: 'zsh' };
|
|
152
|
+
}
|
|
153
|
+
if (shell.includes('bash')) {
|
|
154
|
+
const bashHist = path.join(home, '.bash_history');
|
|
155
|
+
if (fs.existsSync(bashHist)) return { path: bashHist, type: 'bash' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const zshHist = path.join(home, '.zsh_history');
|
|
159
|
+
if (fs.existsSync(zshHist)) return { path: zshHist, type: 'zsh' };
|
|
160
|
+
const bashHist = path.join(home, '.bash_history');
|
|
161
|
+
if (fs.existsSync(bashHist)) return { path: bashHist, type: 'bash' };
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseZshHistory(filePath) {
|
|
167
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
168
|
+
const lines = content.split('\n');
|
|
169
|
+
const entries = [];
|
|
170
|
+
|
|
171
|
+
for (const line of lines) {
|
|
172
|
+
if (!line.startsWith(': ')) continue;
|
|
173
|
+
const match = line.match(/^:\s+(\d+):(\d+);(.*)$/);
|
|
174
|
+
if (match) {
|
|
175
|
+
entries.push({
|
|
176
|
+
timestamp: parseInt(match[1], 10),
|
|
177
|
+
duration: parseInt(match[2], 10),
|
|
178
|
+
command: match[3].trim(),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return entries;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseBashHistory(filePath) {
|
|
186
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
187
|
+
const lines = content.split('\n');
|
|
188
|
+
const entries = [];
|
|
189
|
+
|
|
190
|
+
for (let i = 0; i < lines.length; i++) {
|
|
191
|
+
if (lines[i].startsWith('#')) {
|
|
192
|
+
const timestamp = parseInt(lines[i].slice(1), 10);
|
|
193
|
+
const command = lines[i + 1];
|
|
194
|
+
if (!isNaN(timestamp) && command && command.trim()) {
|
|
195
|
+
entries.push({
|
|
196
|
+
timestamp,
|
|
197
|
+
duration: 0,
|
|
198
|
+
command: command.trim(),
|
|
199
|
+
});
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return entries;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseHistory() {
|
|
208
|
+
const histFile = findHistoryFile();
|
|
209
|
+
if (!histFile) {
|
|
210
|
+
console.error(`${colors.red}${t('errorNoHistory')}${colors.reset}`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
console.error(`${colors.dim}${t('using')}: ${histFile.path} (${histFile.type})${colors.reset}`);
|
|
214
|
+
|
|
215
|
+
if (histFile.type === 'zsh') return parseZshHistory(histFile.path);
|
|
216
|
+
return parseBashHistory(histFile.path);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Time Filtering & Binning ──────────────────────────────────
|
|
220
|
+
function filterByRange(entries, range) {
|
|
221
|
+
const now = Date.now() / 1000;
|
|
222
|
+
let start;
|
|
223
|
+
|
|
224
|
+
switch (range) {
|
|
225
|
+
case 'today':
|
|
226
|
+
start = new Date();
|
|
227
|
+
start.setHours(0, 0, 0, 0);
|
|
228
|
+
start = start.getTime() / 1000;
|
|
229
|
+
break;
|
|
230
|
+
case 'yesterday':
|
|
231
|
+
start = new Date();
|
|
232
|
+
start.setDate(start.getDate() - 1);
|
|
233
|
+
start.setHours(0, 0, 0, 0);
|
|
234
|
+
start = start.getTime() / 1000;
|
|
235
|
+
break;
|
|
236
|
+
case 'week':
|
|
237
|
+
start = now - 7 * 24 * 60 * 60;
|
|
238
|
+
break;
|
|
239
|
+
case 'month':
|
|
240
|
+
start = now - 30 * 24 * 60 * 60;
|
|
241
|
+
break;
|
|
242
|
+
default:
|
|
243
|
+
start = now - 24 * 60 * 60;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return entries.filter(e => e.timestamp >= start && e.timestamp <= now);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getTimeBucketSize(range) {
|
|
250
|
+
switch (range) {
|
|
251
|
+
case 'today':
|
|
252
|
+
case 'yesterday': return 15 * 60;
|
|
253
|
+
case 'week': return 2 * 60 * 60;
|
|
254
|
+
case 'month': return 6 * 60 * 60;
|
|
255
|
+
default: return 15 * 60;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function binByTime(entries, range) {
|
|
260
|
+
if (entries.length === 0) return { bins: [] };
|
|
261
|
+
|
|
262
|
+
const bucketSize = getTimeBucketSize(range);
|
|
263
|
+
const minTime = Math.floor(entries[0].timestamp / bucketSize) * bucketSize;
|
|
264
|
+
const maxTime = Math.ceil(entries[entries.length - 1].timestamp / bucketSize) * bucketSize;
|
|
265
|
+
|
|
266
|
+
const bins = [];
|
|
267
|
+
for (let t = minTime; t <= maxTime; t += bucketSize) {
|
|
268
|
+
const bucketEntries = entries.filter(e => e.timestamp >= t && e.timestamp < t + bucketSize);
|
|
269
|
+
bins.push({ start: t, count: bucketEntries.length });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { bins, bucketSize };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─── Command Ranking ───────────────────────────────────────────
|
|
276
|
+
function rankCommands(entries, topN = 10) {
|
|
277
|
+
const freq = new Map();
|
|
278
|
+
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
const base = getCommandBase(entry.command);
|
|
281
|
+
const cat = classifyCommand(entry.command);
|
|
282
|
+
const key = `${cat.name}:${base}`;
|
|
283
|
+
|
|
284
|
+
if (!freq.has(key)) {
|
|
285
|
+
freq.set(key, { command: base, category: cat, count: 0 });
|
|
286
|
+
}
|
|
287
|
+
freq.get(key).count++;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return Array.from(freq.values())
|
|
291
|
+
.sort((a, b) => b.count - a.count)
|
|
292
|
+
.slice(0, topN);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ─── Rendering ─────────────────────────────────────────────────
|
|
296
|
+
function formatTime(timestamp) {
|
|
297
|
+
const d = new Date(timestamp * 1000);
|
|
298
|
+
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function formatDate(timestamp) {
|
|
302
|
+
const d = new Date(timestamp * 1000);
|
|
303
|
+
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderTopCommands(entries, topN = 10) {
|
|
307
|
+
const ranked = rankCommands(entries, topN);
|
|
308
|
+
if (ranked.length === 0) return [];
|
|
309
|
+
|
|
310
|
+
const maxCount = ranked[0].count;
|
|
311
|
+
const barMaxWidth = 30;
|
|
312
|
+
|
|
313
|
+
const lines = [];
|
|
314
|
+
lines.push(`${colors.bold}${colors.cyan}▎${t('topCommands')} ${topN}${colors.reset}`);
|
|
315
|
+
|
|
316
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
317
|
+
const { command, category, count } = ranked[i];
|
|
318
|
+
const rank = (i + 1).toString().padStart(2);
|
|
319
|
+
const barWidth = Math.max(1, Math.round((count / maxCount) * barMaxWidth));
|
|
320
|
+
const bar = `${category.color}${'█'.repeat(barWidth)}${colors.reset}`;
|
|
321
|
+
const num = i < 3 ? `${colors.bold}${colors.yellow}${rank}${colors.reset}` : `${colors.dim}${rank}${colors.reset}`;
|
|
322
|
+
lines.push(` ${num} ${category.color}${command.padEnd(20)}${colors.reset} ${bar} ${colors.green}${count}${colors.reset} ${t('times')}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return lines;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function renderCategoryBreakdown(entries) {
|
|
329
|
+
const catFreq = new Map();
|
|
330
|
+
for (const entry of entries) {
|
|
331
|
+
const cat = classifyCommand(entry.command);
|
|
332
|
+
catFreq.set(cat.name, (catFreq.get(cat.name) || 0) + 1);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const sorted = Array.from(catFreq.entries()).sort((a, b) => b[1] - a[1]);
|
|
336
|
+
const total = entries.length;
|
|
337
|
+
const barMax = 25;
|
|
338
|
+
|
|
339
|
+
const lines = [];
|
|
340
|
+
lines.push(`${colors.bold}${colors.cyan}▎${t('categories')}${colors.reset}`);
|
|
341
|
+
|
|
342
|
+
for (const [name, count] of sorted) {
|
|
343
|
+
const cat = categories.find(c => c.name === name) || { color: colors.white };
|
|
344
|
+
const pct = ((count / total) * 100).toFixed(1);
|
|
345
|
+
const barLen = Math.round((count / total) * barMax);
|
|
346
|
+
const bar = `${cat.color}${'█'.repeat(Math.max(1, barLen))}${colors.reset}`;
|
|
347
|
+
lines.push(` ${cat.color}${name.padEnd(10)}${colors.reset} ${bar} ${colors.green}${count}${colors.reset} (${pct}%)`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return lines;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function renderDensity(bins) {
|
|
354
|
+
const maxCount = Math.max(...bins.map(b => b.count), 1);
|
|
355
|
+
|
|
356
|
+
function densityColor(level) {
|
|
357
|
+
if (level === 0) return colors.dim;
|
|
358
|
+
if (level < 0.25) return colors.green;
|
|
359
|
+
if (level < 0.5) return colors.yellow;
|
|
360
|
+
if (level < 0.75) return colors.red;
|
|
361
|
+
return colors.magenta;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const terminalWidth = process.stdout.columns || 100;
|
|
365
|
+
const barWidth = Math.max(20, terminalWidth - 6);
|
|
366
|
+
const binsPerCol = Math.max(1, Math.ceil(bins.length / barWidth));
|
|
367
|
+
const columns = [];
|
|
368
|
+
|
|
369
|
+
for (let i = 0; i < bins.length; i += binsPerCol) {
|
|
370
|
+
const group = bins.slice(i, i + binsPerCol);
|
|
371
|
+
const total = group.reduce((sum, b) => sum + b.count, 0);
|
|
372
|
+
columns.push({ start: group[0]?.start, total });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let bar = ' ';
|
|
376
|
+
for (const col of columns) {
|
|
377
|
+
const level = col.total / maxCount;
|
|
378
|
+
bar += `${densityColor(level)}█${colors.reset}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const lines = [];
|
|
382
|
+
lines.push(`${colors.bold}${colors.cyan}▎${t('density')}${colors.reset}`);
|
|
383
|
+
lines.push(bar);
|
|
384
|
+
|
|
385
|
+
const timeMarkers = [];
|
|
386
|
+
const markerInterval = Math.max(1, Math.floor(columns.length / 6));
|
|
387
|
+
for (let i = 0; i < columns.length; i += markerInterval) {
|
|
388
|
+
timeMarkers.push({ pos: i + 2, time: formatTime(columns[i].start) });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let markerLine = ' ';
|
|
392
|
+
let lastEnd = 0;
|
|
393
|
+
for (const m of timeMarkers) {
|
|
394
|
+
const spaces = m.pos - lastEnd;
|
|
395
|
+
markerLine += ' '.repeat(Math.max(0, spaces)) + colors.dim + m.time + colors.reset;
|
|
396
|
+
lastEnd = m.pos + m.time.length;
|
|
397
|
+
}
|
|
398
|
+
lines.push(markerLine);
|
|
399
|
+
lines.push(` ${colors.dim}${t('legend')}: ${colors.green}${t('low')}${colors.reset} ${colors.yellow}${t('medium')}${colors.reset} ${colors.red}${t('high')}${colors.reset} ${colors.magenta}${t('extreme')}${colors.reset}`);
|
|
400
|
+
|
|
401
|
+
return lines;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function renderList(entries) {
|
|
405
|
+
const lines = [];
|
|
406
|
+
lines.push(`${colors.bold}${colors.cyan}▎${t('cmdList')}${colors.reset}`);
|
|
407
|
+
|
|
408
|
+
const terminalWidth = process.stdout.columns || 100;
|
|
409
|
+
const cmdMaxWidth = terminalWidth - 12;
|
|
410
|
+
for (const entry of entries) {
|
|
411
|
+
const cat = classifyCommand(entry.command);
|
|
412
|
+
const timeStr = formatTime(entry.timestamp);
|
|
413
|
+
const cmdStr = entry.command.length > cmdMaxWidth ? entry.command.slice(0, cmdMaxWidth - 3) + '...' : entry.command;
|
|
414
|
+
lines.push(` ${colors.dim}${timeStr}${colors.reset} ${cat.color}${cmdStr}${colors.reset}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return lines;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function renderTimeline(entries, range, format) {
|
|
421
|
+
const filtered = filterByRange(entries, range);
|
|
422
|
+
|
|
423
|
+
if (filtered.length === 0) {
|
|
424
|
+
console.log(`${colors.yellow}${t('noRecords')}${colors.reset}`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const { bins } = binByTime(filtered, range);
|
|
429
|
+
const dateStr = formatDate(filtered[0].timestamp);
|
|
430
|
+
const lastDateStr = formatDate(filtered[filtered.length - 1].timestamp);
|
|
431
|
+
const dateRange = dateStr === lastDateStr ? dateStr : `${dateStr} → ${lastDateStr}`;
|
|
432
|
+
const totalCmds = filtered.length;
|
|
433
|
+
const histInfo = findHistoryFile();
|
|
434
|
+
|
|
435
|
+
// Header
|
|
436
|
+
const lines = [];
|
|
437
|
+
lines.push('');
|
|
438
|
+
lines.push(`${colors.bold}${colors.cyan}${t('title')}${colors.reset} ${colors.dim}${dateRange}${colors.reset}`);
|
|
439
|
+
lines.push(`${t('totalCmds')} ${colors.green}${totalCmds}${colors.reset} ${t('cmdsLabel')} ${colors.dim}·${colors.reset} ${colors.dim}${histInfo?.type || 'N/A'}${colors.reset}`);
|
|
440
|
+
lines.push('');
|
|
441
|
+
|
|
442
|
+
if (format === 'top' || format === 'both' || format === 'stats') {
|
|
443
|
+
lines.push(...renderTopCommands(filtered));
|
|
444
|
+
lines.push('');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (format === 'stats' || format === 'both') {
|
|
448
|
+
lines.push(...renderCategoryBreakdown(filtered));
|
|
449
|
+
lines.push('');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (format === 'density' || format === 'both') {
|
|
453
|
+
lines.push(...renderDensity(bins));
|
|
454
|
+
lines.push('');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (format === 'list' || format === 'both') {
|
|
458
|
+
lines.push(...renderList(filtered));
|
|
459
|
+
lines.push('');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
console.log(lines.join('\n'));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── CLI Arguments ─────────────────────────────────────────────
|
|
466
|
+
function parseArgs() {
|
|
467
|
+
const args = process.argv.slice(2);
|
|
468
|
+
let range = 'today';
|
|
469
|
+
let format = 'both';
|
|
470
|
+
let topN = 10;
|
|
471
|
+
let langArg = null;
|
|
472
|
+
|
|
473
|
+
for (let i = 0; i < args.length; i++) {
|
|
474
|
+
switch (args[i]) {
|
|
475
|
+
case '--range':
|
|
476
|
+
case '-r':
|
|
477
|
+
range = args[++i] || 'today';
|
|
478
|
+
break;
|
|
479
|
+
case '--format':
|
|
480
|
+
case '-f':
|
|
481
|
+
format = args[++i] || 'both';
|
|
482
|
+
break;
|
|
483
|
+
case '--top':
|
|
484
|
+
case '-t':
|
|
485
|
+
topN = parseInt(args[++i], 10) || 10;
|
|
486
|
+
break;
|
|
487
|
+
case '--lang':
|
|
488
|
+
case '-l':
|
|
489
|
+
langArg = args[++i];
|
|
490
|
+
break;
|
|
491
|
+
case '--help':
|
|
492
|
+
case '-h':
|
|
493
|
+
printHelp();
|
|
494
|
+
process.exit(0);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (langArg) {
|
|
499
|
+
lang = langArg === 'zh' ? 'zh' : 'en';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return { range, format, topN };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function printHelp() {
|
|
506
|
+
console.log(t('usage'));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ─── Main ──────────────────────────────────────────────────────
|
|
510
|
+
function main() {
|
|
511
|
+
const { range, format, topN } = parseArgs();
|
|
512
|
+
const entries = parseHistory();
|
|
513
|
+
renderTimeline(entries, range, format);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
main();
|