@mauribadnights/clooks 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +276 -201
- package/agents/clooks.md +146 -0
- package/dist/agent.d.ts +9 -0
- package/dist/agent.js +43 -0
- package/dist/cli.js +126 -17
- package/dist/doctor.js +20 -0
- package/dist/handlers.d.ts +7 -1
- package/dist/handlers.js +105 -3
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -2
- package/dist/manifest.js +33 -0
- package/dist/server.d.ts +21 -1
- package/dist/server.js +126 -8
- package/dist/service.d.ts +27 -0
- package/dist/service.js +242 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +730 -0
- package/dist/types.d.ts +11 -0
- package/docs/DASHBOARD-VISION.md +66 -0
- package/docs/DEFERRED-FIXES.md +35 -0
- package/docs/architecture.png +0 -0
- package/docs/generate-diagram.py +177 -0
- package/package.json +3 -1
package/dist/tui.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks interactive TUI dashboard for `clooks stats -i`
|
|
3
|
+
// Modern terminal UI using raw ANSI escape codes + Node readline
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.launchDashboard = launchDashboard;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const constants_js_1 = require("./constants.js");
|
|
8
|
+
// --- ANSI helpers ---
|
|
9
|
+
const ESC = '\x1b';
|
|
10
|
+
const CSI = `${ESC}[`;
|
|
11
|
+
const ansi = {
|
|
12
|
+
clearScreen: `${CSI}2J`,
|
|
13
|
+
cursorHome: `${CSI}H`,
|
|
14
|
+
hideCursor: `${CSI}?25l`,
|
|
15
|
+
showCursor: `${CSI}?25h`,
|
|
16
|
+
bold: `${CSI}1m`,
|
|
17
|
+
dim: `${CSI}2m`,
|
|
18
|
+
italic: `${CSI}3m`,
|
|
19
|
+
underline: `${CSI}4m`,
|
|
20
|
+
reverse: `${CSI}7m`,
|
|
21
|
+
reset: `${CSI}0m`,
|
|
22
|
+
red: `${CSI}31m`,
|
|
23
|
+
green: `${CSI}32m`,
|
|
24
|
+
yellow: `${CSI}33m`,
|
|
25
|
+
blue: `${CSI}34m`,
|
|
26
|
+
magenta: `${CSI}35m`,
|
|
27
|
+
cyan: `${CSI}36m`,
|
|
28
|
+
white: `${CSI}37m`,
|
|
29
|
+
dimWhite: `${CSI}2;37m`,
|
|
30
|
+
bgBlue: `${CSI}44m`,
|
|
31
|
+
eraseLine: `${CSI}2K`,
|
|
32
|
+
moveTo(row, col) {
|
|
33
|
+
return `${CSI}${row};${col}H`;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
function write(s) {
|
|
37
|
+
process.stdout.write(s);
|
|
38
|
+
}
|
|
39
|
+
function getTermSize() {
|
|
40
|
+
return {
|
|
41
|
+
rows: process.stdout.rows || 24,
|
|
42
|
+
cols: process.stdout.columns || 80,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// --- Data loading ---
|
|
46
|
+
function loadAllMetricEntries() {
|
|
47
|
+
if (!(0, fs_1.existsSync)(constants_js_1.METRICS_FILE))
|
|
48
|
+
return [];
|
|
49
|
+
try {
|
|
50
|
+
const raw = (0, fs_1.readFileSync)(constants_js_1.METRICS_FILE, 'utf-8');
|
|
51
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
52
|
+
return lines.map((line) => JSON.parse(line));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function aggregateByEvent(entries) {
|
|
59
|
+
const byEvent = new Map();
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
const arr = byEvent.get(e.event) ?? [];
|
|
62
|
+
arr.push(e);
|
|
63
|
+
byEvent.set(e.event, arr);
|
|
64
|
+
}
|
|
65
|
+
const result = [];
|
|
66
|
+
for (const [event, evEntries] of byEvent) {
|
|
67
|
+
const durations = evEntries.map((e) => e.duration_ms);
|
|
68
|
+
const byHandler = new Map();
|
|
69
|
+
for (const e of evEntries) {
|
|
70
|
+
const arr = byHandler.get(e.handler) ?? [];
|
|
71
|
+
arr.push(e);
|
|
72
|
+
byHandler.set(e.handler, arr);
|
|
73
|
+
}
|
|
74
|
+
const handlers = [];
|
|
75
|
+
for (const [handler, hEntries] of byHandler) {
|
|
76
|
+
const hDurations = hEntries.map((e) => e.duration_ms);
|
|
77
|
+
handlers.push({
|
|
78
|
+
handler,
|
|
79
|
+
event,
|
|
80
|
+
fires: hEntries.length,
|
|
81
|
+
errors: hEntries.filter((e) => !e.ok).length,
|
|
82
|
+
avgMs: hDurations.reduce((a, b) => a + b, 0) / hDurations.length,
|
|
83
|
+
maxMs: Math.max(...hDurations),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
handlers.sort((a, b) => b.avgMs - a.avgMs);
|
|
87
|
+
result.push({
|
|
88
|
+
event,
|
|
89
|
+
fires: evEntries.length,
|
|
90
|
+
errors: evEntries.filter((e) => !e.ok).length,
|
|
91
|
+
avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
92
|
+
maxMs: Math.max(...durations),
|
|
93
|
+
handlers,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return result.sort((a, b) => b.fires - a.fires);
|
|
97
|
+
}
|
|
98
|
+
function aggregateAllHandlers(entries) {
|
|
99
|
+
const byHandler = new Map();
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
const arr = byHandler.get(e.handler) ?? [];
|
|
102
|
+
arr.push(e);
|
|
103
|
+
byHandler.set(e.handler, arr);
|
|
104
|
+
}
|
|
105
|
+
const result = [];
|
|
106
|
+
for (const [handler, hEntries] of byHandler) {
|
|
107
|
+
const durations = hEntries.map((e) => e.duration_ms);
|
|
108
|
+
result.push({
|
|
109
|
+
handler,
|
|
110
|
+
event: hEntries[0].event,
|
|
111
|
+
fires: hEntries.length,
|
|
112
|
+
errors: hEntries.filter((e) => !e.ok).length,
|
|
113
|
+
avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
114
|
+
maxMs: Math.max(...durations),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return result.sort((a, b) => b.avgMs - a.avgMs);
|
|
118
|
+
}
|
|
119
|
+
function aggregateByAgent(entries) {
|
|
120
|
+
// Group by actual agent_type field from metrics
|
|
121
|
+
const byAgent = new Map();
|
|
122
|
+
let hasAgentData = false;
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
const agent = e.agent_type ?? '';
|
|
125
|
+
if (agent)
|
|
126
|
+
hasAgentData = true;
|
|
127
|
+
const arr = byAgent.get(agent) ?? [];
|
|
128
|
+
arr.push(e);
|
|
129
|
+
byAgent.set(agent, arr);
|
|
130
|
+
}
|
|
131
|
+
// If no entries have agent_type, return empty — the view will show a message
|
|
132
|
+
if (!hasAgentData)
|
|
133
|
+
return [];
|
|
134
|
+
const result = [];
|
|
135
|
+
for (const [agent, aEntries] of byAgent) {
|
|
136
|
+
if (!agent)
|
|
137
|
+
continue; // skip entries with no agent
|
|
138
|
+
const durations = aEntries.map((e) => e.duration_ms);
|
|
139
|
+
const handlers = aggregateHandlersFrom(aEntries);
|
|
140
|
+
result.push({
|
|
141
|
+
agent,
|
|
142
|
+
fires: aEntries.length,
|
|
143
|
+
errors: aEntries.filter((e) => !e.ok).length,
|
|
144
|
+
avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
145
|
+
handlers,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return result.sort((a, b) => b.fires - a.fires);
|
|
149
|
+
}
|
|
150
|
+
function aggregateByProject(entries) {
|
|
151
|
+
const bySid = new Map();
|
|
152
|
+
for (const e of entries) {
|
|
153
|
+
const key = e.session_id ?? 'unknown';
|
|
154
|
+
const arr = bySid.get(key) ?? [];
|
|
155
|
+
arr.push(e);
|
|
156
|
+
bySid.set(key, arr);
|
|
157
|
+
}
|
|
158
|
+
const result = [];
|
|
159
|
+
for (const [sid, pEntries] of bySid) {
|
|
160
|
+
const durations = pEntries.map((e) => e.duration_ms);
|
|
161
|
+
const handlers = aggregateHandlersFrom(pEntries);
|
|
162
|
+
const label = sid.length > 16 ? sid.slice(0, 8) + '..' + sid.slice(-8) : sid;
|
|
163
|
+
result.push({
|
|
164
|
+
project: label,
|
|
165
|
+
fires: pEntries.length,
|
|
166
|
+
errors: pEntries.filter((e) => !e.ok).length,
|
|
167
|
+
avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
168
|
+
handlers,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return result.sort((a, b) => b.fires - a.fires);
|
|
172
|
+
}
|
|
173
|
+
function aggregateHandlersFrom(entries) {
|
|
174
|
+
const byHandler = new Map();
|
|
175
|
+
for (const e of entries) {
|
|
176
|
+
const arr = byHandler.get(e.handler) ?? [];
|
|
177
|
+
arr.push(e);
|
|
178
|
+
byHandler.set(e.handler, arr);
|
|
179
|
+
}
|
|
180
|
+
const result = [];
|
|
181
|
+
for (const [handler, hEntries] of byHandler) {
|
|
182
|
+
const durations = hEntries.map((e) => e.duration_ms);
|
|
183
|
+
result.push({
|
|
184
|
+
handler,
|
|
185
|
+
event: hEntries[0].event,
|
|
186
|
+
fires: hEntries.length,
|
|
187
|
+
errors: hEntries.filter((e) => !e.ok).length,
|
|
188
|
+
avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
|
|
189
|
+
maxMs: Math.max(...durations),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return result.sort((a, b) => b.avgMs - a.avgMs);
|
|
193
|
+
}
|
|
194
|
+
// --- Fuzzy search ---
|
|
195
|
+
function fuzzyMatch(query, target) {
|
|
196
|
+
const q = query.toLowerCase();
|
|
197
|
+
const t = target.toLowerCase();
|
|
198
|
+
let qi = 0;
|
|
199
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
200
|
+
if (t[ti] === q[qi])
|
|
201
|
+
qi++;
|
|
202
|
+
}
|
|
203
|
+
return qi === q.length;
|
|
204
|
+
}
|
|
205
|
+
// --- Color helpers ---
|
|
206
|
+
function colorMs(ms, text) {
|
|
207
|
+
if (ms < 5)
|
|
208
|
+
return `${ansi.green}${text}${ansi.reset}`;
|
|
209
|
+
if (ms <= 50)
|
|
210
|
+
return `${ansi.yellow}${text}${ansi.reset}`;
|
|
211
|
+
return `${ansi.red}${text}${ansi.reset}`;
|
|
212
|
+
}
|
|
213
|
+
function colorErrors(errors, text) {
|
|
214
|
+
if (errors === 0)
|
|
215
|
+
return `${ansi.green}${text}${ansi.reset}`;
|
|
216
|
+
return `${ansi.red}${text}${ansi.reset}`;
|
|
217
|
+
}
|
|
218
|
+
// --- Row formatting ---
|
|
219
|
+
// Strip ANSI codes for length calculation
|
|
220
|
+
function stripAnsi(s) {
|
|
221
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
222
|
+
}
|
|
223
|
+
function pad(text, width, right = false) {
|
|
224
|
+
const visible = stripAnsi(text);
|
|
225
|
+
const diff = width - visible.length;
|
|
226
|
+
if (diff <= 0)
|
|
227
|
+
return text;
|
|
228
|
+
const spaces = ' '.repeat(diff);
|
|
229
|
+
return right ? spaces + text : text + spaces;
|
|
230
|
+
}
|
|
231
|
+
function rpad(text, width) {
|
|
232
|
+
return pad(text, width, true);
|
|
233
|
+
}
|
|
234
|
+
const VIEW_ORDER = ['events', 'handlers', 'agents', 'projects'];
|
|
235
|
+
const VIEW_LABELS = {
|
|
236
|
+
events: 'Events',
|
|
237
|
+
handlers: 'Handlers',
|
|
238
|
+
agents: 'Agents',
|
|
239
|
+
projects: 'Projects',
|
|
240
|
+
};
|
|
241
|
+
// --- Main TUI ---
|
|
242
|
+
function launchDashboard() {
|
|
243
|
+
const entries = loadAllMetricEntries();
|
|
244
|
+
const state = {
|
|
245
|
+
currentView: 'events',
|
|
246
|
+
selectedIndex: 0,
|
|
247
|
+
scrollOffset: 0,
|
|
248
|
+
drillDown: null,
|
|
249
|
+
searchMode: false,
|
|
250
|
+
searchQuery: '',
|
|
251
|
+
entries,
|
|
252
|
+
eventAggs: aggregateByEvent(entries),
|
|
253
|
+
handlerAggs: aggregateAllHandlers(entries),
|
|
254
|
+
agentAggs: aggregateByAgent(entries),
|
|
255
|
+
projectAggs: aggregateByProject(entries),
|
|
256
|
+
};
|
|
257
|
+
// Setup terminal
|
|
258
|
+
process.stdin.setRawMode(true);
|
|
259
|
+
process.stdin.resume();
|
|
260
|
+
process.stdin.setEncoding('utf-8');
|
|
261
|
+
write(ansi.hideCursor);
|
|
262
|
+
write(ansi.clearScreen);
|
|
263
|
+
// Cleanup on exit
|
|
264
|
+
function cleanup() {
|
|
265
|
+
write(ansi.showCursor);
|
|
266
|
+
write(ansi.clearScreen);
|
|
267
|
+
write(ansi.cursorHome);
|
|
268
|
+
process.stdin.setRawMode(false);
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
271
|
+
process.on('SIGINT', cleanup);
|
|
272
|
+
process.on('SIGTERM', cleanup);
|
|
273
|
+
// Handle resize
|
|
274
|
+
process.stdout.on('resize', () => render(state));
|
|
275
|
+
// Input handling
|
|
276
|
+
process.stdin.on('data', (key) => {
|
|
277
|
+
if (state.searchMode) {
|
|
278
|
+
handleSearchInput(state, key, cleanup);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
handleNormalInput(state, key, cleanup);
|
|
282
|
+
}
|
|
283
|
+
render(state);
|
|
284
|
+
});
|
|
285
|
+
// Initial render
|
|
286
|
+
render(state);
|
|
287
|
+
}
|
|
288
|
+
function handleNormalInput(state, key, cleanup) {
|
|
289
|
+
// q or Ctrl+C: quit
|
|
290
|
+
if (key === 'q' || key === '\x03') {
|
|
291
|
+
cleanup();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Tab: next view
|
|
295
|
+
if (key === '\t') {
|
|
296
|
+
switchView(state, 1);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Shift+Tab (ESC [ Z)
|
|
300
|
+
if (key === `${ESC}[Z`) {
|
|
301
|
+
switchView(state, -1);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// Arrow up / k
|
|
305
|
+
if (key === `${ESC}[A` || key === 'k') {
|
|
306
|
+
if (state.selectedIndex > 0)
|
|
307
|
+
state.selectedIndex--;
|
|
308
|
+
adjustScroll(state);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Arrow down / j
|
|
312
|
+
if (key === `${ESC}[B` || key === 'j') {
|
|
313
|
+
const maxIdx = getRowCount(state) - 1;
|
|
314
|
+
if (state.selectedIndex < maxIdx)
|
|
315
|
+
state.selectedIndex++;
|
|
316
|
+
adjustScroll(state);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Enter: drill in
|
|
320
|
+
if (key === '\r') {
|
|
321
|
+
drillIn(state);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Escape or Backspace: go back
|
|
325
|
+
if (key === ESC || key === '\x7f') {
|
|
326
|
+
goBack(state);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// /: search mode
|
|
330
|
+
if (key === '/') {
|
|
331
|
+
state.searchMode = true;
|
|
332
|
+
state.searchQuery = '';
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// 1-4: direct tab selection
|
|
336
|
+
if (key >= '1' && key <= '4') {
|
|
337
|
+
const idx = parseInt(key) - 1;
|
|
338
|
+
state.drillDown = null;
|
|
339
|
+
state.currentView = VIEW_ORDER[idx];
|
|
340
|
+
state.selectedIndex = 0;
|
|
341
|
+
state.scrollOffset = 0;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function handleSearchInput(state, key, _cleanup) {
|
|
346
|
+
// Escape: cancel search
|
|
347
|
+
if (key === ESC || key === '\x03') {
|
|
348
|
+
state.searchMode = false;
|
|
349
|
+
state.searchQuery = '';
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Enter: accept search and stay on filtered view
|
|
353
|
+
if (key === '\r') {
|
|
354
|
+
state.searchMode = false;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// Backspace
|
|
358
|
+
if (key === '\x7f') {
|
|
359
|
+
state.searchQuery = state.searchQuery.slice(0, -1);
|
|
360
|
+
state.selectedIndex = 0;
|
|
361
|
+
state.scrollOffset = 0;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Printable character
|
|
365
|
+
if (key.length === 1 && key.charCodeAt(0) >= 32) {
|
|
366
|
+
state.searchQuery += key;
|
|
367
|
+
state.selectedIndex = 0;
|
|
368
|
+
state.scrollOffset = 0;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function switchView(state, direction) {
|
|
372
|
+
state.drillDown = null;
|
|
373
|
+
state.searchQuery = '';
|
|
374
|
+
const idx = VIEW_ORDER.indexOf(state.currentView);
|
|
375
|
+
const next = (idx + direction + VIEW_ORDER.length) % VIEW_ORDER.length;
|
|
376
|
+
state.currentView = VIEW_ORDER[next];
|
|
377
|
+
state.selectedIndex = 0;
|
|
378
|
+
state.scrollOffset = 0;
|
|
379
|
+
}
|
|
380
|
+
function drillIn(state) {
|
|
381
|
+
if (state.drillDown)
|
|
382
|
+
return; // already drilled in
|
|
383
|
+
if (state.currentView === 'events') {
|
|
384
|
+
const ev = state.eventAggs[state.selectedIndex];
|
|
385
|
+
if (ev) {
|
|
386
|
+
state.drillDown = ev;
|
|
387
|
+
state.selectedIndex = 0;
|
|
388
|
+
state.scrollOffset = 0;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (state.currentView === 'agents') {
|
|
392
|
+
const ag = state.agentAggs[state.selectedIndex];
|
|
393
|
+
if (ag) {
|
|
394
|
+
state.drillDown = ag;
|
|
395
|
+
state.selectedIndex = 0;
|
|
396
|
+
state.scrollOffset = 0;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
else if (state.currentView === 'projects') {
|
|
400
|
+
const pr = state.projectAggs[state.selectedIndex];
|
|
401
|
+
if (pr) {
|
|
402
|
+
state.drillDown = pr;
|
|
403
|
+
state.selectedIndex = 0;
|
|
404
|
+
state.scrollOffset = 0;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function goBack(state) {
|
|
409
|
+
if (state.searchQuery) {
|
|
410
|
+
state.searchQuery = '';
|
|
411
|
+
state.selectedIndex = 0;
|
|
412
|
+
state.scrollOffset = 0;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (state.drillDown) {
|
|
416
|
+
state.drillDown = null;
|
|
417
|
+
state.selectedIndex = 0;
|
|
418
|
+
state.scrollOffset = 0;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function getRowCount(state) {
|
|
422
|
+
const rows = getRows(state);
|
|
423
|
+
return rows.length;
|
|
424
|
+
}
|
|
425
|
+
function adjustScroll(state) {
|
|
426
|
+
const { rows } = getTermSize();
|
|
427
|
+
const contentHeight = rows - 6; // header(2) + separator(1) + footer separator(1) + footer(2)
|
|
428
|
+
if (state.selectedIndex < state.scrollOffset) {
|
|
429
|
+
state.scrollOffset = state.selectedIndex;
|
|
430
|
+
}
|
|
431
|
+
else if (state.selectedIndex >= state.scrollOffset + contentHeight) {
|
|
432
|
+
state.scrollOffset = state.selectedIndex - contentHeight + 1;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function getRows(state) {
|
|
436
|
+
const query = state.searchQuery.toLowerCase();
|
|
437
|
+
if (state.entries.length === 0) {
|
|
438
|
+
return [
|
|
439
|
+
{ label: 'No metrics recorded yet.' },
|
|
440
|
+
{ label: '' },
|
|
441
|
+
{ label: 'Start the clooks daemon and run some Claude Code sessions' },
|
|
442
|
+
{ label: 'to see hook execution statistics here.' },
|
|
443
|
+
];
|
|
444
|
+
}
|
|
445
|
+
if (state.currentView === 'events') {
|
|
446
|
+
return getEventsRows(state, query);
|
|
447
|
+
}
|
|
448
|
+
else if (state.currentView === 'handlers') {
|
|
449
|
+
return getHandlersRows(state, query);
|
|
450
|
+
}
|
|
451
|
+
else if (state.currentView === 'agents') {
|
|
452
|
+
return getAgentsRows(state, query);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
return getProjectsRows(state, query);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function makeEventRow(ev) {
|
|
459
|
+
const name = pad(ev.event, 22);
|
|
460
|
+
const fires = rpad(String(ev.fires), 8);
|
|
461
|
+
const errs = colorErrors(ev.errors, rpad(String(ev.errors), 8));
|
|
462
|
+
const avg = colorMs(ev.avgMs, rpad(ev.avgMs.toFixed(1), 9));
|
|
463
|
+
const max = colorMs(ev.maxMs, rpad(ev.maxMs.toFixed(1), 9));
|
|
464
|
+
return `${name}${fires}${errs}${avg}${max}`;
|
|
465
|
+
}
|
|
466
|
+
function makeHandlerRow(h) {
|
|
467
|
+
const name = pad(h.handler, 28);
|
|
468
|
+
const ev = pad(h.event, 18);
|
|
469
|
+
const fires = rpad(String(h.fires), 8);
|
|
470
|
+
const errs = colorErrors(h.errors, rpad(String(h.errors), 8));
|
|
471
|
+
const avg = colorMs(h.avgMs, rpad(h.avgMs.toFixed(1), 9));
|
|
472
|
+
const max = colorMs(h.maxMs, rpad(h.maxMs.toFixed(1), 9));
|
|
473
|
+
return `${name}${ev}${fires}${errs}${avg}${max}`;
|
|
474
|
+
}
|
|
475
|
+
function eventHeader() {
|
|
476
|
+
const name = pad('Event', 22);
|
|
477
|
+
const fires = rpad('Fires', 8);
|
|
478
|
+
const errs = rpad('Errors', 8);
|
|
479
|
+
const avg = rpad('Avg ms', 9);
|
|
480
|
+
const max = rpad('Max ms', 9);
|
|
481
|
+
return { label: `${name}${fires}${errs}${avg}${max}`, isHeader: true };
|
|
482
|
+
}
|
|
483
|
+
function handlerHeader() {
|
|
484
|
+
const name = pad('Handler', 28);
|
|
485
|
+
const ev = pad('Event', 18);
|
|
486
|
+
const fires = rpad('Fires', 8);
|
|
487
|
+
const errs = rpad('Errors', 8);
|
|
488
|
+
const avg = rpad('Avg ms', 9);
|
|
489
|
+
const max = rpad('Max ms', 9);
|
|
490
|
+
return { label: `${name}${ev}${fires}${errs}${avg}${max}`, isHeader: true };
|
|
491
|
+
}
|
|
492
|
+
function getEventsRows(state, query) {
|
|
493
|
+
if (state.drillDown && 'event' in state.drillDown && 'handlers' in state.drillDown) {
|
|
494
|
+
const ev = state.drillDown;
|
|
495
|
+
const rows = [handlerHeader()];
|
|
496
|
+
for (const h of ev.handlers) {
|
|
497
|
+
if (query && !fuzzyMatch(query, h.handler))
|
|
498
|
+
continue;
|
|
499
|
+
rows.push({ label: makeHandlerRow(h) });
|
|
500
|
+
}
|
|
501
|
+
return rows;
|
|
502
|
+
}
|
|
503
|
+
const rows = [eventHeader()];
|
|
504
|
+
for (const ev of state.eventAggs) {
|
|
505
|
+
if (query && !fuzzyMatch(query, ev.event))
|
|
506
|
+
continue;
|
|
507
|
+
rows.push({ label: makeEventRow(ev) });
|
|
508
|
+
}
|
|
509
|
+
return rows;
|
|
510
|
+
}
|
|
511
|
+
function getHandlersRows(state, query) {
|
|
512
|
+
const rows = [handlerHeader()];
|
|
513
|
+
for (const h of state.handlerAggs) {
|
|
514
|
+
if (query && !fuzzyMatch(query, h.handler) && !fuzzyMatch(query, h.event))
|
|
515
|
+
continue;
|
|
516
|
+
rows.push({ label: makeHandlerRow(h) });
|
|
517
|
+
}
|
|
518
|
+
return rows;
|
|
519
|
+
}
|
|
520
|
+
function getAgentsRows(state, query) {
|
|
521
|
+
if (state.agentAggs.length === 0) {
|
|
522
|
+
return [
|
|
523
|
+
{ label: 'No agent data available.' },
|
|
524
|
+
{ label: '' },
|
|
525
|
+
{ label: 'Agent tracking requires sessions launched with:' },
|
|
526
|
+
{ label: ` ${ansi.cyan}claude --agent <name>${ansi.reset}` },
|
|
527
|
+
{ label: '' },
|
|
528
|
+
{ label: 'The agent_type is recorded on SessionStart and' },
|
|
529
|
+
{ label: 'associated with all subsequent hook events in that session.' },
|
|
530
|
+
];
|
|
531
|
+
}
|
|
532
|
+
if (state.drillDown && 'agent' in state.drillDown) {
|
|
533
|
+
const ag = state.drillDown;
|
|
534
|
+
const rows = [handlerHeader()];
|
|
535
|
+
for (const h of ag.handlers) {
|
|
536
|
+
if (query && !fuzzyMatch(query, h.handler))
|
|
537
|
+
continue;
|
|
538
|
+
rows.push({ label: makeHandlerRow(h) });
|
|
539
|
+
}
|
|
540
|
+
return rows;
|
|
541
|
+
}
|
|
542
|
+
const nameW = 22;
|
|
543
|
+
const rows = [{
|
|
544
|
+
label: `${pad('Agent', nameW)}${rpad('Fires', 8)}${rpad('Errors', 8)}${rpad('Avg ms', 9)}${rpad('Handlers', 10)}`,
|
|
545
|
+
isHeader: true,
|
|
546
|
+
}];
|
|
547
|
+
for (const ag of state.agentAggs) {
|
|
548
|
+
if (query && !fuzzyMatch(query, ag.agent))
|
|
549
|
+
continue;
|
|
550
|
+
const name = pad(ag.agent, nameW);
|
|
551
|
+
const fires = rpad(String(ag.fires), 8);
|
|
552
|
+
const errs = colorErrors(ag.errors, rpad(String(ag.errors), 8));
|
|
553
|
+
const avg = colorMs(ag.avgMs, rpad(ag.avgMs.toFixed(1), 9));
|
|
554
|
+
const hCount = rpad(String(ag.handlers.length), 10);
|
|
555
|
+
rows.push({ label: `${name}${fires}${errs}${avg}${hCount}` });
|
|
556
|
+
}
|
|
557
|
+
return rows;
|
|
558
|
+
}
|
|
559
|
+
function getProjectsRows(state, query) {
|
|
560
|
+
if (state.drillDown && 'project' in state.drillDown) {
|
|
561
|
+
const pr = state.drillDown;
|
|
562
|
+
const rows = [handlerHeader()];
|
|
563
|
+
for (const h of pr.handlers) {
|
|
564
|
+
if (query && !fuzzyMatch(query, h.handler))
|
|
565
|
+
continue;
|
|
566
|
+
rows.push({ label: makeHandlerRow(h) });
|
|
567
|
+
}
|
|
568
|
+
return rows;
|
|
569
|
+
}
|
|
570
|
+
const nameW = 22;
|
|
571
|
+
const rows = [{
|
|
572
|
+
label: `${pad('Session', nameW)}${rpad('Fires', 8)}${rpad('Errors', 8)}${rpad('Avg ms', 9)}`,
|
|
573
|
+
isHeader: true,
|
|
574
|
+
}];
|
|
575
|
+
for (const pr of state.projectAggs) {
|
|
576
|
+
if (query && !fuzzyMatch(query, pr.project))
|
|
577
|
+
continue;
|
|
578
|
+
const name = pad(pr.project, nameW);
|
|
579
|
+
const fires = rpad(String(pr.fires), 8);
|
|
580
|
+
const errs = colorErrors(pr.errors, rpad(String(pr.errors), 8));
|
|
581
|
+
const avg = colorMs(pr.avgMs, rpad(pr.avgMs.toFixed(1), 9));
|
|
582
|
+
rows.push({ label: `${name}${fires}${errs}${avg}` });
|
|
583
|
+
}
|
|
584
|
+
return rows;
|
|
585
|
+
}
|
|
586
|
+
// --- Rendering ---
|
|
587
|
+
function render(state) {
|
|
588
|
+
const { rows: termRows, cols } = getTermSize();
|
|
589
|
+
const allRows = getRows(state);
|
|
590
|
+
// Layout: line 1 = title + tabs, line 2 = separator, lines 3..N-3 = content,
|
|
591
|
+
// line N-2 = separator, line N-1 = search/status, line N = keybindings
|
|
592
|
+
const headerLines = 2;
|
|
593
|
+
const footerLines = 3;
|
|
594
|
+
const contentHeight = termRows - headerLines - footerLines;
|
|
595
|
+
write(ansi.cursorHome);
|
|
596
|
+
// --- Header line 1: title + tab bar ---
|
|
597
|
+
const drillLabel = state.drillDown
|
|
598
|
+
? ` > ${('event' in state.drillDown) ? state.drillDown.event :
|
|
599
|
+
('agent' in state.drillDown) ? state.drillDown.agent :
|
|
600
|
+
state.drillDown.project}`
|
|
601
|
+
: '';
|
|
602
|
+
let tabBar = '';
|
|
603
|
+
for (const v of VIEW_ORDER) {
|
|
604
|
+
if (v === state.currentView) {
|
|
605
|
+
tabBar += `${ansi.reverse}${ansi.bold} ${VIEW_LABELS[v]} ${ansi.reset} `;
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
tabBar += `${ansi.dim} ${VIEW_LABELS[v]} ${ansi.reset} `;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const searchIndicator = state.searchQuery
|
|
612
|
+
? ` ${ansi.yellow}/${state.searchQuery}${ansi.reset}`
|
|
613
|
+
: '';
|
|
614
|
+
const titleLeft = ` ${ansi.bold}clooks stats${ansi.reset}${drillLabel}`;
|
|
615
|
+
const titleRight = `q: quit `;
|
|
616
|
+
const headerLine = titleLeft + searchIndicator
|
|
617
|
+
+ ' '.repeat(Math.max(1, cols - stripAnsi(titleLeft + searchIndicator).length - titleRight.length))
|
|
618
|
+
+ `${ansi.dim}${titleRight}${ansi.reset}`;
|
|
619
|
+
write(ansi.eraseLine + headerLine + '\n');
|
|
620
|
+
// Tabs line
|
|
621
|
+
write(ansi.eraseLine + ' ' + tabBar + '\n');
|
|
622
|
+
// --- Separator ---
|
|
623
|
+
// (rendered as part of content area below)
|
|
624
|
+
// --- Content ---
|
|
625
|
+
// Adjust selectedIndex to skip header rows
|
|
626
|
+
// Find the first non-header index
|
|
627
|
+
let selectableStart = 0;
|
|
628
|
+
for (let i = 0; i < allRows.length; i++) {
|
|
629
|
+
if (!allRows[i].isHeader) {
|
|
630
|
+
selectableStart = i;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Clamp selectedIndex to selectable rows only
|
|
635
|
+
const selectableRows = allRows.filter(r => !r.isHeader);
|
|
636
|
+
if (state.selectedIndex >= selectableRows.length) {
|
|
637
|
+
state.selectedIndex = Math.max(0, selectableRows.length - 1);
|
|
638
|
+
}
|
|
639
|
+
// Map selectedIndex to absolute index in allRows
|
|
640
|
+
let selectableCount = 0;
|
|
641
|
+
let absSelectedIndex = -1;
|
|
642
|
+
for (let i = 0; i < allRows.length; i++) {
|
|
643
|
+
if (!allRows[i].isHeader) {
|
|
644
|
+
if (selectableCount === state.selectedIndex) {
|
|
645
|
+
absSelectedIndex = i;
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
selectableCount++;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// Adjust scroll for absolute index
|
|
652
|
+
if (absSelectedIndex >= 0) {
|
|
653
|
+
if (absSelectedIndex < state.scrollOffset) {
|
|
654
|
+
state.scrollOffset = absSelectedIndex;
|
|
655
|
+
}
|
|
656
|
+
else if (absSelectedIndex >= state.scrollOffset + contentHeight) {
|
|
657
|
+
state.scrollOffset = absSelectedIndex - contentHeight + 1;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Ensure header row (index 0) is always visible by starting scroll at 0 min
|
|
661
|
+
if (state.scrollOffset > 0 && allRows.length > 0 && allRows[0].isHeader) {
|
|
662
|
+
// Keep the header pinned — we'll render it separately
|
|
663
|
+
}
|
|
664
|
+
// Render content rows
|
|
665
|
+
const hasHeader = allRows.length > 0 && allRows[0].isHeader;
|
|
666
|
+
let renderedLines = 0;
|
|
667
|
+
if (hasHeader) {
|
|
668
|
+
// Pin the column header
|
|
669
|
+
const hdr = allRows[0].label;
|
|
670
|
+
write(ansi.eraseLine + ` ${ansi.dim}${ansi.underline}${hdr}${ansi.reset}` + '\n');
|
|
671
|
+
renderedLines++;
|
|
672
|
+
}
|
|
673
|
+
const dataStart = hasHeader ? 1 : 0;
|
|
674
|
+
const visibleCount = contentHeight - renderedLines;
|
|
675
|
+
// scrollOffset applies to data rows only (after header)
|
|
676
|
+
const dataRows = allRows.slice(dataStart);
|
|
677
|
+
const startIdx = state.scrollOffset;
|
|
678
|
+
const endIdx = Math.min(dataRows.length, startIdx + visibleCount);
|
|
679
|
+
// Map selected to data-relative index
|
|
680
|
+
const dataSelectedIdx = absSelectedIndex >= 0 ? absSelectedIndex - dataStart : -1;
|
|
681
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
682
|
+
const row = dataRows[i];
|
|
683
|
+
const isSelected = (i === dataSelectedIdx - state.scrollOffset + state.scrollOffset)
|
|
684
|
+
// Simpler: is this data index === absSelectedIndex - dataStart?
|
|
685
|
+
&& false; // placeholder
|
|
686
|
+
// Check if this data row is selected
|
|
687
|
+
const selected = (i + dataStart) === absSelectedIndex;
|
|
688
|
+
if (selected) {
|
|
689
|
+
write(ansi.eraseLine + ` ${ansi.bold}${ansi.cyan}\u25b8${ansi.reset} ${ansi.bold}${row.label}${ansi.reset}` + '\n');
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
write(ansi.eraseLine + ` ${row.label}` + '\n');
|
|
693
|
+
}
|
|
694
|
+
renderedLines++;
|
|
695
|
+
}
|
|
696
|
+
// Fill remaining lines with blanks
|
|
697
|
+
for (let i = renderedLines; i < contentHeight; i++) {
|
|
698
|
+
write(ansi.eraseLine + '\n');
|
|
699
|
+
}
|
|
700
|
+
// --- Footer ---
|
|
701
|
+
const totalFires = state.entries.length;
|
|
702
|
+
const totalErrors = state.entries.filter(e => !e.ok).length;
|
|
703
|
+
const totalEvents = state.eventAggs.length;
|
|
704
|
+
const totalHandlers = state.handlerAggs.length;
|
|
705
|
+
// Separator
|
|
706
|
+
write(ansi.eraseLine + ` ${ansi.dim}${'─'.repeat(Math.max(0, cols - 4))}${ansi.reset}` + '\n');
|
|
707
|
+
// Status line
|
|
708
|
+
const statusInfo = `${totalEvents} events ${totalHandlers} handlers ${totalFires} fires ${totalErrors} errors`;
|
|
709
|
+
write(ansi.eraseLine + ` ${ansi.dim}${statusInfo}${ansi.reset}` + '\n');
|
|
710
|
+
// Keybinding line
|
|
711
|
+
let keyHelp;
|
|
712
|
+
if (state.searchMode) {
|
|
713
|
+
keyHelp = 'Type to filter Enter: accept Esc: cancel';
|
|
714
|
+
}
|
|
715
|
+
else if (state.drillDown) {
|
|
716
|
+
keyHelp = 'Esc: back Tab: switch view j/k: navigate /: search q: quit';
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
keyHelp = 'Tab: switch view j/k: navigate Enter: drill in /: search 1-4: jump to tab q: quit';
|
|
720
|
+
}
|
|
721
|
+
write(ansi.eraseLine + ` ${ansi.dim}${keyHelp}${ansi.reset}`);
|
|
722
|
+
// If in search mode, show the cursor in the search field
|
|
723
|
+
if (state.searchMode) {
|
|
724
|
+
// Position cursor at the search indicator in header
|
|
725
|
+
write(ansi.showCursor);
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
write(ansi.hideCursor);
|
|
729
|
+
}
|
|
730
|
+
}
|