ai-usage-analyzer 0.3.2 → 0.4.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/bin/ai-usage.js +53 -30
- package/package.json +1 -1
- package/src/aggregate.js +41 -21
- package/src/loaders.js +173 -94
- package/src/markdown.js +127 -66
- package/src/render.js +503 -185
- package/src/tui.js +310 -0
package/src/tui.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Interactive TUI: tab bar + scrollable content + keyboard navigation.
|
|
2
|
+
// Falls back gracefully when stdout is not a TTY.
|
|
3
|
+
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
import { perProject } from "./aggregate.js";
|
|
8
|
+
import {
|
|
9
|
+
CURSOR_MARK,
|
|
10
|
+
renderDetections,
|
|
11
|
+
renderNotes,
|
|
12
|
+
renderOverview,
|
|
13
|
+
renderPerMonth,
|
|
14
|
+
renderPerProjectCursor,
|
|
15
|
+
renderPerTool,
|
|
16
|
+
renderPerToolPerMonth,
|
|
17
|
+
renderPerWeek,
|
|
18
|
+
renderProjectSessions,
|
|
19
|
+
renderTopSessions,
|
|
20
|
+
} from "./render.js";
|
|
21
|
+
|
|
22
|
+
// Strip ANSI escape codes to measure visible string length.
|
|
23
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
24
|
+
function visLen(s) {
|
|
25
|
+
return s.replace(ANSI_RE, "").length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runInteractive({ records, detections, errors, topN }) {
|
|
29
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) return false;
|
|
30
|
+
|
|
31
|
+
const PROJECTS_VIEW = 1; // index into VIEWS
|
|
32
|
+
|
|
33
|
+
const VIEWS = [
|
|
34
|
+
{
|
|
35
|
+
key: "1",
|
|
36
|
+
label: "Overview",
|
|
37
|
+
fn: () => renderOverview(records, detections),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "2",
|
|
41
|
+
label: "Projects",
|
|
42
|
+
// rendered specially via proj state — fn used only for non-interactive fallback
|
|
43
|
+
fn: () => renderPerProjectCursor(records, proj.cursor),
|
|
44
|
+
},
|
|
45
|
+
{ key: "3", label: "Per Tool", fn: () => renderPerTool(records) },
|
|
46
|
+
{ key: "4", label: "Tool/Month", fn: () => renderPerToolPerMonth(records) },
|
|
47
|
+
{ key: "5", label: "Per Month", fn: () => renderPerMonth(records) },
|
|
48
|
+
{ key: "6", label: "Per Week", fn: () => renderPerWeek(records) },
|
|
49
|
+
{
|
|
50
|
+
key: "7",
|
|
51
|
+
label: `Top ${topN}`,
|
|
52
|
+
fn: () => renderTopSessions(records, topN),
|
|
53
|
+
},
|
|
54
|
+
{ key: "8", label: "Detections", fn: () => renderDetections(detections) },
|
|
55
|
+
{ key: "9", label: "Notes", fn: () => renderNotes(detections, errors) },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// Project drill-down state (only active when current === PROJECTS_VIEW).
|
|
59
|
+
const proj = { mode: "list", cursor: 0, name: null };
|
|
60
|
+
const projectItems = () => perProject(records);
|
|
61
|
+
|
|
62
|
+
// One reserved row each for tab bar and status bar.
|
|
63
|
+
const CHROME = 2;
|
|
64
|
+
|
|
65
|
+
function usableRows() {
|
|
66
|
+
return Math.max(1, (process.stdout.rows || 24) - CHROME);
|
|
67
|
+
}
|
|
68
|
+
function termWidth() {
|
|
69
|
+
return process.stdout.columns || 100;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let current = 0;
|
|
73
|
+
let scrollY = 0;
|
|
74
|
+
let lines = []; // raw rendered lines of the current view
|
|
75
|
+
let dirty = true; // full redraw needed (e.g. after resize)
|
|
76
|
+
|
|
77
|
+
function loadView() {
|
|
78
|
+
let raw;
|
|
79
|
+
if (current === PROJECTS_VIEW) {
|
|
80
|
+
raw =
|
|
81
|
+
proj.mode === "detail"
|
|
82
|
+
? renderProjectSessions(records, proj.name)
|
|
83
|
+
: renderPerProjectCursor(records, proj.cursor);
|
|
84
|
+
} else {
|
|
85
|
+
raw = VIEWS[current].fn() || chalk.dim(" (no data)");
|
|
86
|
+
}
|
|
87
|
+
lines = raw.split("\n");
|
|
88
|
+
// Auto-scroll so the cursor row stays visible in list mode.
|
|
89
|
+
if (current === PROJECTS_VIEW && proj.mode === "list") {
|
|
90
|
+
const markerLine = lines.findIndex((l) => l.includes(CURSOR_MARK));
|
|
91
|
+
if (markerLine >= 0) {
|
|
92
|
+
const usable = usableRows();
|
|
93
|
+
if (markerLine < scrollY) scrollY = markerLine;
|
|
94
|
+
else if (markerLine >= scrollY + usable)
|
|
95
|
+
scrollY = markerLine - usable + 1;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
scrollY = 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tabBar() {
|
|
103
|
+
const w = termWidth();
|
|
104
|
+
const tabs = VIEWS.map((v, i) => {
|
|
105
|
+
const label = ` ${v.key}:${v.label} `;
|
|
106
|
+
return i === current ? chalk.bgCyan.black.bold(label) : chalk.dim(label);
|
|
107
|
+
}).join(chalk.dim("│"));
|
|
108
|
+
const pad = Math.max(0, w - visLen(tabs));
|
|
109
|
+
return tabs + " ".repeat(pad);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function statusBar() {
|
|
113
|
+
const w = termWidth();
|
|
114
|
+
const usable = usableRows();
|
|
115
|
+
const total = lines.length;
|
|
116
|
+
const pct =
|
|
117
|
+
total <= usable
|
|
118
|
+
? chalk.dim(" all ")
|
|
119
|
+
: chalk.dim(` ${Math.round(((scrollY + usable) / total) * 100)}% `);
|
|
120
|
+
|
|
121
|
+
let hint;
|
|
122
|
+
if (current === PROJECTS_VIEW && proj.mode === "list") {
|
|
123
|
+
hint = chalk.dim(" ↑/↓ navigate Enter select ←/→ switch view q quit");
|
|
124
|
+
} else if (current === PROJECTS_VIEW && proj.mode === "detail") {
|
|
125
|
+
hint = chalk.dim(" ↑/↓ j/k scroll PgUp/PgDn Esc back q quit");
|
|
126
|
+
} else {
|
|
127
|
+
hint = chalk.dim(
|
|
128
|
+
" ←/→ h/l switch ↑/↓ j/k scroll PgUp/PgDn g/G top/bot q quit",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const gap = Math.max(1, w - visLen(hint) - visLen(pct));
|
|
132
|
+
return chalk.bgBlack(hint + " ".repeat(gap) + pct);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Clip a line to w visible chars, preserving ANSI sequences.
|
|
136
|
+
function clipLine(line, w) {
|
|
137
|
+
const visible = line.replace(ANSI_RE, "");
|
|
138
|
+
if (visible.length <= w) return line;
|
|
139
|
+
let kept = 0,
|
|
140
|
+
out = "";
|
|
141
|
+
for (let i = 0; i < line.length; ) {
|
|
142
|
+
if (line[i] === "\x1b") {
|
|
143
|
+
const end = line.indexOf("m", i);
|
|
144
|
+
if (end !== -1) {
|
|
145
|
+
out += line.slice(i, end + 1);
|
|
146
|
+
i = end + 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (kept < w) {
|
|
151
|
+
out += line[i];
|
|
152
|
+
kept++;
|
|
153
|
+
}
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
return out + "\x1b[0m";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function draw(forceFullClear) {
|
|
160
|
+
const rows = process.stdout.rows || 24;
|
|
161
|
+
const usable = Math.max(1, rows - CHROME);
|
|
162
|
+
const w = termWidth();
|
|
163
|
+
const maxScroll = Math.max(0, lines.length - usable);
|
|
164
|
+
if (scrollY > maxScroll) scrollY = maxScroll;
|
|
165
|
+
|
|
166
|
+
// Full clear on first draw or resize; partial rewrite otherwise.
|
|
167
|
+
if (forceFullClear) process.stdout.write("\x1b[2J");
|
|
168
|
+
|
|
169
|
+
// Tab bar pinned to row 1.
|
|
170
|
+
process.stdout.write("\x1b[1;1H" + clipLine(tabBar(), w) + "\x1b[K");
|
|
171
|
+
|
|
172
|
+
// Content rows 2 … rows-1.
|
|
173
|
+
const slice = lines.slice(scrollY, scrollY + usable);
|
|
174
|
+
while (slice.length < usable) slice.push("");
|
|
175
|
+
for (let i = 0; i < slice.length; i++) {
|
|
176
|
+
process.stdout.write(
|
|
177
|
+
`\x1b[${i + 2};1H` + clipLine(slice[i], w) + "\x1b[K",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Status bar pinned to last row.
|
|
182
|
+
process.stdout.write(
|
|
183
|
+
`\x1b[${rows};1H` + clipLine(statusBar(), w) + "\x1b[K",
|
|
184
|
+
);
|
|
185
|
+
dirty = false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
readline.emitKeypressEvents(process.stdin);
|
|
189
|
+
process.stdin.setRawMode(true);
|
|
190
|
+
process.stdin.resume();
|
|
191
|
+
process.stdin.setEncoding("utf8");
|
|
192
|
+
|
|
193
|
+
loadView();
|
|
194
|
+
// Enter alternate screen buffer so the TUI never clobbers terminal scrollback.
|
|
195
|
+
process.stdout.write("\x1b[?1049h\x1b[?25l");
|
|
196
|
+
draw(true);
|
|
197
|
+
|
|
198
|
+
process.stdout.on("resize", () => {
|
|
199
|
+
draw(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
function quit() {
|
|
204
|
+
// Leave alternate screen — original terminal content is restored automatically.
|
|
205
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
206
|
+
process.stdin.setRawMode(false);
|
|
207
|
+
process.stdin.pause();
|
|
208
|
+
resolve();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
process.stdin.on("keypress", (_str, key) => {
|
|
212
|
+
if (!key) return;
|
|
213
|
+
const usable = usableRows();
|
|
214
|
+
const maxScroll = Math.max(0, lines.length - usable);
|
|
215
|
+
|
|
216
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
217
|
+
quit();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Projects view special handling ---
|
|
222
|
+
if (current === PROJECTS_VIEW) {
|
|
223
|
+
if (proj.mode === "list") {
|
|
224
|
+
const items = projectItems();
|
|
225
|
+
if (key.name === "down" || key.name === "j") {
|
|
226
|
+
proj.cursor = Math.min(proj.cursor + 1, items.length - 1);
|
|
227
|
+
loadView();
|
|
228
|
+
draw(false);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (key.name === "up" || key.name === "k") {
|
|
232
|
+
proj.cursor = Math.max(0, proj.cursor - 1);
|
|
233
|
+
loadView();
|
|
234
|
+
draw(false);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (key.name === "return") {
|
|
238
|
+
if (items.length > 0) {
|
|
239
|
+
proj.name = items[proj.cursor].project;
|
|
240
|
+
proj.mode = "detail";
|
|
241
|
+
loadView();
|
|
242
|
+
draw(false);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// detail mode — Esc goes back to list
|
|
248
|
+
if (key.name === "escape" || key.name === "backspace") {
|
|
249
|
+
proj.mode = "list";
|
|
250
|
+
loadView();
|
|
251
|
+
draw(false);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- Global view switching (not available in Projects detail mode) ---
|
|
258
|
+
if (!(current === PROJECTS_VIEW && proj.mode === "detail")) {
|
|
259
|
+
if (key.name === "right" || key.name === "l" || key.name === "tab") {
|
|
260
|
+
current = (current + 1) % VIEWS.length;
|
|
261
|
+
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
262
|
+
loadView();
|
|
263
|
+
draw(false);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (key.name === "left" || key.name === "h") {
|
|
267
|
+
current = (current - 1 + VIEWS.length) % VIEWS.length;
|
|
268
|
+
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
269
|
+
loadView();
|
|
270
|
+
draw(false);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (_str && /^[1-9]$/.test(_str)) {
|
|
274
|
+
const idx = parseInt(_str, 10) - 1;
|
|
275
|
+
if (idx < VIEWS.length) {
|
|
276
|
+
current = idx;
|
|
277
|
+
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
278
|
+
loadView();
|
|
279
|
+
draw(false);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Scrolling
|
|
286
|
+
let moved = false;
|
|
287
|
+
if (key.name === "down" || key.name === "j") {
|
|
288
|
+
scrollY = Math.min(scrollY + 1, maxScroll);
|
|
289
|
+
moved = true;
|
|
290
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
291
|
+
scrollY = Math.max(0, scrollY - 1);
|
|
292
|
+
moved = true;
|
|
293
|
+
} else if (key.name === "pagedown" || key.name === "space") {
|
|
294
|
+
scrollY = Math.min(scrollY + usable, maxScroll);
|
|
295
|
+
moved = true;
|
|
296
|
+
} else if (key.name === "pageup") {
|
|
297
|
+
scrollY = Math.max(0, scrollY - usable);
|
|
298
|
+
moved = true;
|
|
299
|
+
} else if (key.name === "home" || key.name === "g") {
|
|
300
|
+
scrollY = 0;
|
|
301
|
+
moved = true;
|
|
302
|
+
} else if (key.name === "end" || key.name === "G") {
|
|
303
|
+
scrollY = maxScroll;
|
|
304
|
+
moved = true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (moved) draw(false);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|